Skip to content
Open
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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ yarn add react-native-enriched-markdown react-native-worklets remend

---

#### Using worklets below 0.10

The worklet path needs Bundle Mode `importForwarding`, added in `react-native-worklets` 0.10.
Apps pinned lower, for instance through `react-native-reanimated@4.3.x` which peers
`react-native-worklets@0.8.x`, will hit:

```
[Worklets] Tried to synchronously call a non-worklet function on the UI thread
```

Install past the peer warning (yarn and pnpm warn; npm needs `--legacy-peer-deps` or an
`overrides` entry) and run remend on the JS thread instead:

```tsx
<StreamdownText markdown={markdown} forceJsThread />
```

remend then shares the JS thread with your UI and re-parses the whole document on every update, so
batch your updates, or treat this as a bridge until you can move to 0.10.

## Required setup — Bundle Mode

`react-native-streamdown` runs markdown processing on a worklet thread using **Bundle Mode** from `react-native-worklets`. This requires extra configuration steps from the [official Bundle Mode setup guide](https://docs.swmansion.com/react-native-worklets/docs/bundleMode/setup/). Make sure to complete these steps before continuing. For a real-world reference of an app configured with Bundle Mode, check out the [Bundle Mode Showcase App](https://github.com/software-mansion-labs/Bundle-Mode-showcase-app).
Expand Down Expand Up @@ -151,11 +171,12 @@ import { StreamdownText } from 'react-native-streamdown';

### Props

`StreamdownText` accepts all props from `EnrichedMarkdownText` plus one additional prop:
`StreamdownText` accepts all props from `EnrichedMarkdownText` plus two additional props:

| Prop | Type | Description |
| -------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `remendConfig` | `RemendOptions` | Optional. Override the default remend processing config. See [remend docs](https://www.npmjs.com/package/remend) for all available options. |
| `forceJsThread` | `boolean` | Optional, defaults to `false`. Runs remend on the JS thread instead of a worklet runtime. Required on `react-native-worklets` below `0.10`. |

Pass `flavor="github"` to render GitHub Flavored Markdown.

Expand Down
21 changes: 21 additions & 0 deletions example/src/StreamingMarkdownSimulator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const STREAMING_INTERVAL = 50;

export default function StreamingMarkdownSimulator() {
const [currentIndex, setCurrentIndex] = useState(0);
// Tap the toggle to compare the worklet runtime against the JS thread.
const [forceJsThread, setForceJsThread] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);

const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
Expand Down Expand Up @@ -86,6 +88,15 @@ export default function StreamingMarkdownSimulator() {
</Text>
</View>

<Pressable
style={styles.threadToggle}
onPress={() => setForceJsThread((on) => !on)}
>
<Text style={styles.threadToggleText}>
remend runs on: {forceJsThread ? 'JS thread' : 'worklet runtime'}
</Text>
</Pressable>

<View style={styles.buttonRow}>
<ControlButton
label={isStreaming ? 'Streaming...' : 'Start'}
Expand Down Expand Up @@ -114,6 +125,7 @@ export default function StreamingMarkdownSimulator() {
markdown={partialMarkdown}
flavor="github"
remendConfig={{ katex: true }}
forceJsThread={forceJsThread}
onLinkPress={(e) => handleLinkPress(e.url)}
/>
{isStreaming && <Text style={styles.streamingDot}>● Streaming…</Text>}
Expand Down Expand Up @@ -155,6 +167,15 @@ const styles = StyleSheet.create({
progressFill: { height: '100%', borderRadius: 3 },
progressText: { fontSize: 11, color: '#666', textAlign: 'center' },
buttonRow: { flexDirection: 'row', gap: 8 },
threadToggle: {
marginBottom: 10,
paddingVertical: 7,
borderRadius: 8,
borderWidth: 1,
borderColor: '#2196F3',
alignItems: 'center',
},
threadToggleText: { fontSize: 12, fontWeight: '600', color: '#2196F3' },
button: {
flex: 1,
paddingVertical: 10,
Expand Down
2 changes: 2 additions & 0 deletions src/StreamdownText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ export function StreamdownText({
markdown,
remendConfig,
selectable = true,
forceJsThread = false,
...enrichedMarkdownProps
}: StreamdownTextProps) {
const { processedMarkdown, isStreaming } = useStreamdownMarkdown(markdown, {
remendConfig,
forceJsThread,
});

return (
Expand Down
94 changes: 94 additions & 0 deletions src/__tests__/StreamdownText.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { act } from 'react';
import TestRenderer from 'react-test-renderer';
import { StreamdownText } from '../StreamdownText';

// Only packages outside this library are stubbed. StreamdownText, useStreamdownMarkdown
// and remendWorklet all run for real, so each case covers the whole path from prop to
// rendered markdown rather than one unit in isolation.

// Inline literal: this factory is evaluated during the hoisted import of the module
// under test, before any module-scope const has been initialised.
jest.mock('react-native', () => ({ Platform: { OS: 'ios' } }));

const mockScheduleOnRuntime = jest.fn();
const mockScheduleOnRN = jest.fn();

jest.mock('react-native-worklets', () => ({
createWorkletRuntime: jest.fn(() => ({ __runtime: 'remend' })),
// Run the scheduled work inline so a test can assert on what finally renders.
scheduleOnRuntime: (runtime: unknown, work: () => void) => {
mockScheduleOnRuntime(runtime, work);
work();
},
scheduleOnRN: (fn: (...a: unknown[]) => void, ...args: unknown[]) => {
mockScheduleOnRN(fn, ...args);
fn(...args);
},
}));

// remend ships only an "import" condition, which jest's CommonJS resolver cannot load.
jest.mock(
'remend',
() => ({ __esModule: true, default: (md: string) => `remended:${md}` }),
{ virtual: true }
);

const mockRendered: Record<string, unknown>[] = [];
jest.mock('react-native-enriched-markdown', () => ({
EnrichedMarkdownText: (props: Record<string, unknown>) => {
mockRendered.push(props);
return null;
},
}));

const lastRender = () => mockRendered[mockRendered.length - 1];

function render(element: React.ReactElement) {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(element);
});
return renderer;
}

describe('StreamdownText', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRendered.length = 0;
});

it('renders processed markdown through the worklet runtime by default', () => {
render(<StreamdownText markdown="hello" />);

expect(mockScheduleOnRuntime).toHaveBeenCalledTimes(1);
expect(lastRender()).toMatchObject({ markdown: 'remended:hello' });
});

it('renders processed markdown on the JS thread when forceJsThread is set', () => {
render(<StreamdownText markdown="hello" forceJsThread />);

expect(mockScheduleOnRuntime).not.toHaveBeenCalled();
expect(lastRender()).toMatchObject({ markdown: 'remended:hello' });
});

it('keeps forceJsThread out of the underlying markdown view', () => {
render(<StreamdownText markdown="hello" flavor="github" forceJsThread />);

expect(lastRender()).not.toHaveProperty('forceJsThread');
expect(lastRender()).toMatchObject({ flavor: 'github' });
});

it('reprocesses the current markdown when forceJsThread changes', () => {
const renderer = render(<StreamdownText markdown="hello" />);
expect(mockScheduleOnRuntime).toHaveBeenCalledTimes(1);

act(() => {
renderer.update(<StreamdownText markdown="hello" forceJsThread />);
});

// Reprocessed, and the second pass skipped the runtime: one scheduleOnRuntime
// from the first render, two deliveries in total.
expect(mockScheduleOnRuntime).toHaveBeenCalledTimes(1);
expect(mockScheduleOnRN).toHaveBeenCalledTimes(2);
});
});
8 changes: 6 additions & 2 deletions src/hooks/useStreamdownMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { RemendOptions } from 'remend';

interface UseStreamdownMarkdownOptions {
remendConfig?: RemendOptions;
forceJsThread?: boolean;
}

interface UseStreamdownMarkdownResult {
Expand All @@ -23,6 +24,8 @@ export function useStreamdownMarkdown(
// remendConfig is a fresh object every render and would otherwise reschedule
// the worklet job on every commit.
const remendConfigKey = JSON.stringify(options?.remendConfig);
// In the dependency list below so flipping it reprocesses the current markdown.
const forceJsThread = options?.forceJsThread ?? false;

useEffect(() => {
if (markdown === '') {
Expand All @@ -42,11 +45,12 @@ export function useStreamdownMarkdown(
setIsStreaming(false);
}
},
options?.remendConfig
options?.remendConfig,
forceJsThread
);
// Reads the live options?.remendConfig; remendConfigKey gates re-runs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [markdown, remendConfigKey]);
}, [markdown, remendConfigKey, forceJsThread]);

return { processedMarkdown, isStreaming };
}
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,13 @@ export interface StreamdownTextProps extends EnrichedMarkdownTextProps {
* Optional custom remend configuration.
*/
remendConfig?: RemendOptions;

/**
* Run remend on the JS thread instead of a worklet runtime. Needed on
* `react-native-worklets` below 0.10, which cannot resolve `remend` inside a
* worklet. Slower, since the work now shares the JS thread with your UI.
*
* @default false
*/
forceJsThread?: boolean;
}
8 changes: 6 additions & 2 deletions src/worklets/remendWorklet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,17 @@ const remendRuntime =
export function processRemendInWorklet(
markdown: string,
onComplete: (result: string) => void,
config?: RemendOptions
config?: RemendOptions,
forceJsThread = false
) {
const mergedConfig = config
? { ...defaultRemendConfig, ...config }
: defaultRemendConfig;

if (remendRuntime == null) {
// No runtime on web, and worklets before 0.10 cannot resolve `remend` inside one.
const runOnJsThread = remendRuntime == null || forceJsThread;

if (runOnJsThread) {
scheduleOnRN(onComplete, remend(markdown, mergedConfig));
return;
}
Expand Down