Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions android/src/main/cpp/MarkdownParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ namespace livemarkdown {
jni::alias_ref<jhybridobject> jThis,
jni::alias_ref<jni::JString> 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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<MarkdownRange> parse(@NonNull String text, int parserId) {
try {
Expand All @@ -53,6 +55,13 @@ public synchronized List<MarkdownRange> 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<MarkdownRange> markdownRanges = new LinkedList<>();
try {
Systrace.beginSection(0, "markdownRanges");
Expand Down
4 changes: 3 additions & 1 deletion apple/MarkdownParser.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 23 additions & 11 deletions apple/MarkdownParser.mm
Original file line number Diff line number Diff line change
Expand Up @@ -171,20 +171,28 @@ - (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();
}
}
}

- (NSArray<MarkdownRange *> *)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<MarkdownRange *> *)parseIfRegistered:(nonnull NSString *)text
withParserId:(nonnull NSNumber *)parserId
{
NSArray<MarkdownRange *> *cached = [self cachedRangesForText:text withParserId:parserId];
if (cached != nil) {
Expand All @@ -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<MarkdownRange *> *markdownRanges = [self parseUncached:text withParserId:parserId];
if (markdownRanges == nil) {
return nil;
}

[self cacheMarkdownRanges:markdownRanges forText:text withParserId:parserId];

return markdownRanges;
}

- (NSArray<MarkdownRange *> *)parseUncached:(nonnull NSString *)text
withParserId:(nonnull NSNumber *)parserId
- (nullable NSArray<MarkdownRange *> *)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<SerializableWorklet> 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;
Expand Down
10 changes: 4 additions & 6 deletions cpp/MarkdownGlobal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,22 @@ std::shared_ptr<WorkletRuntime> getMarkdownRuntime() {

std::unordered_map<int, std::shared_ptr<SerializableWorklet>> globalMarkdownShareableWorklets;
std::mutex globalMarkdownShareableWorkletsMutex;
int nextParserId = 1;

const int registerMarkdownWorklet(const std::shared_ptr<SerializableWorklet> &markdownWorklet) {
void registerMarkdownWorklet(const int parserId, const std::shared_ptr<SerializableWorklet> &markdownWorklet) {
assert(markdownWorklet != nullptr);
auto parserId = nextParserId++;
std::unique_lock<std::mutex> lock(globalMarkdownShareableWorkletsMutex);
globalMarkdownShareableWorklets[parserId] = markdownWorklet;
return parserId;
}

void unregisterMarkdownWorklet(const int parserId) {
std::unique_lock<std::mutex> lock(globalMarkdownShareableWorkletsMutex);
globalMarkdownShareableWorklets.erase(parserId);
}

std::shared_ptr<SerializableWorklet> getMarkdownWorklet(const int parserId) {
std::shared_ptr<SerializableWorklet> findMarkdownWorklet(const int parserId) {
std::unique_lock<std::mutex> lock(globalMarkdownShareableWorkletsMutex);
return globalMarkdownShareableWorklets.at(parserId);
const auto it = globalMarkdownShareableWorklets.find(parserId);
return it == globalMarkdownShareableWorklets.end() ? nullptr : it->second;
}

} // namespace livemarkdown
Expand Down
7 changes: 5 additions & 2 deletions cpp/MarkdownGlobal.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ void setMarkdownRuntime(const std::shared_ptr<WorkletRuntime> &markdownWorkletRu

std::shared_ptr<WorkletRuntime> getMarkdownRuntime();

const int registerMarkdownWorklet(const std::shared_ptr<SerializableWorklet> &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<SerializableWorklet> &markdownWorklet);

void unregisterMarkdownWorklet(const int parserId);

std::shared_ptr<SerializableWorklet> getMarkdownWorklet(const int parserId);
// Returns nullptr when nothing is registered under `parserId`.
std::shared_ptr<SerializableWorklet> findMarkdownWorklet(const int parserId);

} // namespace livemarkdown
} // namespace expensify
7 changes: 4 additions & 3 deletions cpp/RuntimeDecorator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<SerializableWorklet>(rt, args[0]));
return jsi::Value(parserId);
const auto parserId = static_cast<int>(args[0].asNumber());
registerMarkdownWorklet(parserId, extractSerializableOrThrow<SerializableWorklet>(rt, args[1]));
return jsi::Value::undefined();
}));

rt.global().setProperty(rt, "jsi_unregisterMarkdownWorklet", jsi::Function::createFromHostFunction(
Expand Down
25 changes: 25 additions & 0 deletions example/src/AlwaysPaintedView.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof NativeComponentRegistry.get>[1];
type StyleAttributes = NonNullable<
NonNullable<ReturnType<ViewConfigProvider>['validAttributes']>['style']
>;

// React hides a host view under a hidden <Activity> 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<ViewProps>(
'AlwaysPaintedView',
() => ({
uiViewClassName: 'RCTView',
validAttributes: {style: pinnedDisplayStyleAttributes},
}),
);

export default AlwaysPaintedView;
100 changes: 83 additions & 17 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<ActivityWrapper>('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(() => {
Expand All @@ -36,27 +62,39 @@ export default function App() {

const ref = React.useRef<MarkdownTextInput>(null);

const input = (
<MarkdownTextInput
multiline={multiline}
formatSelection={handleFormatSelection}
autoCapitalize="none"
caretHidden={caretHidden}
value={value}
onChangeText={setValue}
style={[styles.input, style]}
ref={ref}
markdownStyle={markdownStyle}
parser={useStrikethroughParser ? strikethroughParser : parser}
placeholder="Type here..."
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
selection={selection}
id={TEST_CONST.INPUT_ID}
maxLength={30000}
/>
);

return (
<ScrollView contentContainerStyle={styles.container} style={styles.content}>
<PlatformInfo />
<Text>{multiline ? 'multiline' : 'singleline'}</Text>
<MarkdownTextInput
multiline={multiline}
formatSelection={handleFormatSelection}
autoCapitalize="none"
caretHidden={caretHidden}
value={value}
onChangeText={setValue}
style={[styles.input, style]}
ref={ref}
markdownStyle={markdownStyle}
parser={parseExpensiMark}
placeholder="Type here..."
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
selection={selection}
id={TEST_CONST.INPUT_ID}
maxLength={30000}
/>
<React.Activity mode={activityHidden ? 'hidden' : 'visible'}>
{activityWrapper === 'alwaysPainted' ? (
<AlwaysPaintedView style={styles.alwaysPainted}>
{input}
</AlwaysPaintedView>
) : (
input
)}
</React.Activity>
<Text style={styles.text}>{JSON.stringify(value)}</Text>
<Button
testID="focus"
Expand Down Expand Up @@ -122,6 +160,31 @@ export default function App() {
title="Toggle caret hidden"
onPress={() => setCaretHidden(prev => !prev)}
/>
<Button
title={
activityWrapper === 'none'
? 'Use AlwaysPaintedView'
: 'Use regular view'
}
disabled={activityHidden}
onPress={() =>
setActivityWrapper(prev =>
prev === 'none' ? 'alwaysPainted' : 'none',
)
}
/>
<Button
title={activityHidden ? 'Show Activity' : 'Hide Activity'}
onPress={() => setActivityHidden(prev => !prev)}
/>
<Button
title={
useStrikethroughParser
? 'Use ExpensiMark parser'
: 'Use strikethrough parser'
}
onPress={() => setUseStrikethroughParser(prev => !prev)}
/>
<Button
title="Toggle all"
onPress={() => {
Expand Down Expand Up @@ -154,6 +217,9 @@ const styles = StyleSheet.create({
content: {
marginTop: 60,
},
alwaysPainted: {
display: 'contents',
},
input: {
fontSize: 20,
width: 300,
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading