Skip to content

Commit b515a7a

Browse files
authored
Merge pull request #1 from software-mansion-labs/feat/initial-streamdown-implementation
feat: implement StreamdownText with worklet-based markdown processing
2 parents e5c0fcd + 3960a5b commit b515a7a

24 files changed

Lines changed: 1955 additions & 188 deletions

.github/workflows/ci.yml

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,3 @@ jobs:
5656

5757
- name: Build package
5858
run: yarn prepare
59-
60-
build-web:
61-
runs-on: ubuntu-latest
62-
63-
steps:
64-
- name: Checkout
65-
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
66-
67-
- name: Setup
68-
uses: ./.github/actions/setup
69-
70-
- name: Build example for Web
71-
run: |
72-
yarn example expo export --platform web
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
diff --git a/src/node-haste/DependencyGraph.js b/src/node-haste/DependencyGraph.js
2+
index bd42485a8a6647757c0f5b2f2c4a5e659125fcdd..417de6c997067b4b1c686c35fe5007ba199b3897 100644
3+
--- a/src/node-haste/DependencyGraph.js
4+
+++ b/src/node-haste/DependencyGraph.js
5+
@@ -186,6 +186,14 @@ class DependencyGraph extends _events.default {
6+
return (0, _nullthrows.default)(this._fileSystem).getAllFiles();
7+
}
8+
async getOrComputeSha1(mixedPath) {
9+
+ if (mixedPath.includes("react-native-worklets/.worklets/")) {
10+
+ const createHash = require("crypto").createHash;
11+
+ return {
12+
+ sha1: createHash("sha1")
13+
+ .update(performance.now().toString())
14+
+ .digest("hex"),
15+
+ };
16+
+ }
17+
const result = await this._fileSystem.getOrComputeSha1(mixedPath);
18+
if (!result || !result.sha1) {
19+
throw new Error(`Failed to get the SHA-1 for: ${mixedPath}.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
diff --git a/src/modules/HMRClient.js b/src/modules/HMRClient.js
2+
index 3e2652d7a43ff0d2ab3ad7556ecf79f88f7a6edb..47de354ddfd3858187048e84b5a62b412a28c640 100644
3+
--- a/src/modules/HMRClient.js
4+
+++ b/src/modules/HMRClient.js
5+
@@ -2,6 +2,9 @@
6+
7+
const EventEmitter = require("./vendor/eventemitter3");
8+
const inject = ({ module: [id, code], sourceURL }) => {
9+
+ if (global.__workletsModuleProxy?.propagateModuleUpdate) {
10+
+ global.__workletsModuleProxy.propagateModuleUpdate(code, sourceURL);
11+
+ }
12+
if (global.globalEvalWithSourceUrl) {
13+
global.globalEvalWithSourceUrl(code, sourceURL);
14+
} else {
15+
diff --git a/src/polyfills/require.js b/src/polyfills/require.js
16+
index 367cabb90c6e016dc024d4f7fadfee6c3a52ca32..c6399316700b0295441a42ba19d29b171e54015b 100644
17+
--- a/src/polyfills/require.js
18+
+++ b/src/polyfills/require.js
19+
@@ -334,14 +334,14 @@ function unknownModuleError(id) {
20+
}
21+
return Error(message);
22+
}
23+
+metroRequire.getModules = () => {
24+
+ return modules;
25+
+};
26+
if (__DEV__) {
27+
metroRequire.Systrace = {
28+
beginEvent: () => {},
29+
endEvent: () => {},
30+
};
31+
- metroRequire.getModules = () => {
32+
- return modules;
33+
- };
34+
var createHotReloadingObject = function () {
35+
const hot = {
36+
_acceptCallback: null,

CONTRIBUTING.md

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,6 @@ Running "StreamdownExample" with {"fabric":true,"initialProps":{"concurrentRoot"
5353

5454
Note the `"fabric":true` and `"concurrentRoot":true` properties.
5555

56-
To run the example app on Web:
57-
58-
```sh
59-
yarn example web
60-
```
61-
6256
Make sure your code passes TypeScript:
6357

6458
```sh

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2026 Gregory Moskaliuk
3+
Copyright (c) 2026 Software Mansion
44
Permission is hereby granted, free of charge, to any person obtaining a copy
55
of this software and associated documentation files (the "Software"), to deal
66
in the Software without restriction, including without limitation the rights

README.md

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,138 @@
11
# react-native-streamdown
22

3-
Markdown Streaming
3+
A streaming-ready markdown component for React Native built on top of [`react-native-enriched-markdown`](https://github.com/Expensify/react-native-enriched-markdown) and [`remend`](https://www.npmjs.com/package/remend).
4+
5+
It processes raw, incomplete markdown (as it streams token-by-token from an LLM) in the background using [`react-native-worklets`](https://docs.swmansion.com/react-native-worklets/docs/) powerful concurrency feature - the Bundle Mode - keeping the JS thread free at all times.
6+
7+
## Features
8+
9+
- Renders incomplete streaming markdown correctly — no visual glitches mid-stream
10+
- Background thread processing via `react-native-worklets` Bundle Mode
11+
- Inline LaTeX support (`$...$`) with streaming completion — applied automatically, no configuration needed (we've also opened a [PR to add this directly to remend](https://github.com/vercel/streamdown/pull/446))
12+
- CommonMark rendering (headers, bold, italic, inline code, fenced code blocks, links, images) powered by `react-native-enriched-markdown` with built-in `streamingAnimation`
13+
- Customizable via `remendConfig`
14+
15+
---
416

517
## Installation
618

19+
```sh
20+
yarn add react-native-streamdown
21+
```
22+
23+
### Peer dependencies
724

825
```sh
9-
npm install react-native-streamdown
26+
yarn add react-native-enriched-markdown react-native-worklets remend
1027
```
1128

29+
| Package | Version |
30+
| -------------------------------- | ----------------------------- |
31+
| `react-native-enriched-markdown` | `0.4.0` |
32+
| `react-native-worklets` | `0.8.0-bundle-mode-preview-2` |
33+
| `remend` | `1.2.2` |
1234

13-
## Usage
35+
---
36+
37+
## Required setup — Bundle Mode
1438

39+
`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).
40+
41+
### 1. `babel.config.js` — configure Worklets Babel plugin
42+
43+
`react-native-streamdown` requires special options to be added to the Worklets Babel plugin config in `babel.config.js`:
1544

1645
```js
17-
import { multiply } from 'react-native-streamdown';
46+
const workletsPluginOptions = {
47+
bundleMode: true,
48+
// other options...
49+
workletizableModules: ['remend'], // add this line
50+
};
51+
```
52+
53+
`workletizableModules: ['remend']` tells the Babel plugin to pre-bundle `remend` for the worklet runtime so it can be called off the JS thread.
54+
55+
### 2. `metro.config.js` — configure Metro for monorepos
1856

19-
// ...
57+
`react-native-worklets` Bundle Mode generates files on the fly that might not be tracked by Metro in some monorepo setups. It might also shadow your resolving function. If you're running into issues with module resolution, you need to add the following to your `metro.config.js`:
2058

21-
const result = await multiply(3, 7);
59+
```js
60+
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
61+
const { bundleModeMetroConfig } = require('react-native-worklets/bundleMode');
62+
63+
let config = getDefaultConfig(__dirname);
64+
65+
// Watch the .worklets/ output directory
66+
config.watchFolders.push(
67+
require('path').resolve(
68+
__dirname,
69+
'node_modules/react-native-worklets/.worklets'
70+
)
71+
);
72+
73+
// Resolve react-native-worklets/.worklets/* via the Bundle Mode resolver
74+
const defaultResolver = config.resolver.resolveRequest;
75+
76+
config = mergeConfig(config, bundleModeMetroConfig);
77+
78+
config.resolver.resolveRequest = (context, moduleName, platform) => {
79+
if (moduleName.startsWith('react-native-worklets/.worklets/')) {
80+
return bundleModeMetroConfig.resolver.resolveRequest(
81+
context,
82+
moduleName,
83+
platform
84+
);
85+
}
86+
return defaultResolver(context, moduleName, platform);
87+
};
88+
89+
module.exports = config;
2290
```
2391

92+
---
2493

25-
## Contributing
94+
## Usage
2695

27-
- [Development workflow](CONTRIBUTING.md#development-workflow)
28-
- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
29-
- [Code of conduct](CODE_OF_CONDUCT.md)
96+
```tsx
97+
import { StreamdownText } from 'react-native-streamdown';
3098

31-
## License
99+
// rawMarkdown can be updated token-by-token as the LLM streams
100+
<StreamdownText rawMarkdown={partialMarkdown} />;
101+
```
32102

33-
MIT
103+
### Props
104+
105+
`StreamdownText` accepts all props from `EnrichedMarkdownText` (except `flavor`, which is hardcoded to `commonmark`) plus one additional prop:
106+
107+
| Prop | Type | Description |
108+
| -------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
109+
| `remendConfig` | `RemendOptions` | Optional. Override the default remend processing config. See [remend docs](https://www.npmjs.com/package/remend) for all available options. |
110+
111+
---
112+
113+
## Example app
114+
115+
The `example/` directory in this repository contains a fully working demo app that shows:
116+
117+
- **Streaming Markdown Simulator** — streams a sample markdown document token-by-token to demonstrate rendering quality and the `streamingAnimation` effect
118+
- **LLM Streaming Demo** — connects to the OpenAI Chat Completions API via SSE and renders the response live using `StreamdownText`
119+
120+
It is a practical reference for the full Bundle Mode setup (Babel, Metro, `package.json` flags) and for how to wire `StreamdownText` into a real streaming UI.
121+
122+
---
123+
124+
## Limitations
125+
126+
- **CommonMark only**`StreamdownText` currently renders using the `commonmark` flavour of `react-native-enriched-markdown`. GitHub Flavored Markdown (GFM) support is planned for a future release.
127+
128+
---
129+
130+
Built by [Software Mansion](https://swmansion.com/).
131+
132+
[<img width="128" height="69" alt="Software Mansion Logo" src="https://github.com/user-attachments/assets/f0e18471-a7aa-4e80-86ac-87686a86fe56" />](https://swmansion.com/)
34133

35134
---
36135

37-
Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
136+
## License
137+
138+
MIT

example/babel.config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@ const pkg = require('../package.json');
44

55
const root = path.resolve(__dirname, '..');
66

7+
const workletsPluginOptions = {
8+
bundleMode: true,
9+
strictGlobal: true,
10+
workletizableModules: ['remend'],
11+
};
12+
713
module.exports = getConfig(
814
{
915
presets: ['module:@react-native/babel-preset'],
16+
plugins: [['react-native-worklets/plugin', workletsPluginOptions]],
1017
},
1118
{ root, pkg }
1219
);

example/index.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import { AppRegistry } from 'react-native';
2+
import { SafeAreaProvider } from 'react-native-safe-area-context';
23
import App from './src/App';
34
import { name as appName } from './app.json';
45

5-
AppRegistry.registerComponent(appName, () => App);
6+
function Root() {
7+
return (
8+
<SafeAreaProvider>
9+
<App />
10+
</SafeAreaProvider>
11+
);
12+
}
13+
14+
AppRegistry.registerComponent(appName, () => Root);

0 commit comments

Comments
 (0)