Skip to content

feat: add forceJsThread to run remend on the JS thread - #37

Open
arnaud-zg wants to merge 3 commits into
software-mansion-labs:mainfrom
arnaud-zg:feat/force-js-thread
Open

arnaud-zg wants to merge 3 commits into
software-mansion-labs:mainfrom
arnaud-zg:feat/force-js-thread

Conversation

@arnaud-zg

@arnaud-zg arnaud-zg commented Sep 18, 2026 •

Copy link
Copy Markdown

Why we need this. At Doctolib we are building a streaming assistant chat in our React Native
apps, and StreamdownText is exactly the right component for it. We cannot adopt it: our apps are
pinned to react-native-reanimated@~4.3.2, which hard-peers react-native-worklets@0.8.x.

The error, and why

On worklets below 0.10, the first streamed token throws:

[Worklets] Tried to synchronously call a non-worklet function `vn` on the UI thread
  remoteFunctionHolder   bundleUnpacker.native.ts:29

The worklet path hands the remend import to a worklet runtime. That needs Bundle Mode
importForwarding, and importForwarding first exists in worklets 0.10.0. On 0.8.x the
bundle unpacker cannot resolve remend, so the guard fires. Nothing else is missing.

What changes

remendWorklet.ts already has a JS-thread path, added for web in #28. This makes it reachable on
native too, behind one opt-in prop. Two lines of logic. Nothing existing changes.

flowchart LR
  MD([streaming markdown]) --> Q{forceJsThread?}
  Q -->|off · default| W["worklet runtime<br/><small>needs worklets ≥ 0.10</small>"]
  Q -->|on · new| J["JS thread<br/><small>works on 0.8.x</small>"]
  W --> R([rendered])
  J --> R
Loading
<StreamdownText markdown={markdown} forceJsThread />

✅ 10 tests · lint · typecheck · build  ·  iOS + Android  ·  peerDependencies unchanged

Built test-first

Three commits, and the test output at each one is real, not retyped.

commit yarn test StreamdownText yarn typecheck
🔴 test: describe the behaviour 3 failed, 1 passed exit 2
🟢 feat: implement it 4 passed exit 0
🔵 refactor: clean it up 4 passed exit 0
The three runs

🔴 red — the prop does not exist yet, so three cases fail and typecheck rejects the test.
The fourth pins today's default so the next commit cannot change it by accident.

✓ renders processed markdown through the worklet runtime by default
✕ renders processed markdown on the JS thread when forceJsThread is set
✕ keeps forceJsThread out of the underlying markdown view
✕ reprocesses the current markdown when forceJsThread changes

Tests: 3 failed, 1 passed, 4 total      typecheck exit: 2

🟢 green — the two-line change below, nothing else.

✓ renders processed markdown through the worklet runtime by default
✓ renders processed markdown on the JS thread when forceJsThread is set
✓ keeps forceJsThread out of the underlying markdown view
✓ reprocesses the current markdown when forceJsThread changes

Tests: 4 passed, 4 total                typecheck exit: 0

🔵 refactor — the two reasons to skip the runtime become one named condition. Same four
tests, untouched, still green.

Tests: 4 passed, 4 total                typecheck exit: 0

The red commit is the only one that fails, which is the point of it. lint, typecheck,
test and prepare all pass at the head of the branch, which is what CI runs.

Demo

react-native-worklets@0.8.3, same build, one tap apart.

❌ Default (worklet runtime) ✅ With forceJsThread
01-default-worklet-fails 02-forcejsthread-works
Screen recording
03-forcejsthread-demo.mp4

How it works
  export function processRemendInWorklet(
    markdown: string,
    onComplete: (result: string) => void,
-   config?: RemendOptions
+   config?: RemendOptions,
+   forceJsThread = false
  ) {
-   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;
    }

forceJsThread joins the existing web condition, so both reasons to skip the runtime share one
branch. The flag is threaded through useStreamdownMarkdown and exposed as a prop on
StreamdownText, where it is destructured out so it never reaches EnrichedMarkdownText.

The module-scope createWorkletRuntime() is deliberately left alone. Making it lazy was tempting,
but it works fine on 0.8.3 and deferring it would have inverted the
creates a worklet runtime at module load on native assertion added in #31.

Which worklets versions have what

Checked against the worklets-* tags in software-mansion/react-native-reanimated:

0.8.3 0.9.3 0.10.0
createWorkletRuntime, scheduleOnRuntime, scheduleOnRN yes yes yes
babel bundleMode yes yes yes
babel importForwarding no no yes

peerDependencies stays >=0.10.0, since that is still what the default path needs. Apps below it
install past the peer warning and opt in themselves, which the README now documents.

How it was tested
worklets 0.10.2, default renders, iOS + Android
worklets 0.8.3, default reproduces the error above, iOS + Android
worklets 0.8.3, forceJsThread renders fully, iOS + Android

4 new tests in one file, driven through the public component so StreamdownText, the hook and
remendWorklet all run for real; only packages outside this library are stubbed. No existing
test file was modified at all.

Reproduce: in example/, pin react-native-worklets@0.8.3 and react-native-reanimated@4.3.2,
rebuild, then tap the remend runs on: toggle.

Why a prop, not an automatic fallback

remend re-parses the whole document on every update. On Hermes a 16 KB document costs around
340 ms per pass, so falling back silently would trade a loud error for a stall nobody opted into.
We would rather choose that trade-off explicitly, and document the cost.

If you would prefer something automatic, two options I have not implemented: a one-shot probe
worklet that checks typeof remend === 'function' and reports back through scheduleOnRN, or a
dev-only warning when the installed worklets version is below 0.10. Happy to add either.


AI assistance (Claude) was used for research and drafting. I reviewed and verified everything
myself, including the device testing.

arnaud-zg and others added 3 commits September 18, 2026 19:20
Drives the desired behaviour from the public component, stubbing only the
packages outside this library so StreamdownText, useStreamdownMarkdown and
remendWorklet all run for real.

Three of the four cases fail: there is no way to reach the JS-thread branch on
native yet. The fourth pins today's default behaviour so the next commit cannot
change it by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Arnaud Zheng <arnaud.zheng@doctolib.com>
Co-Authored-By: Bacary Bruno Bodian <brunobacary.bodian@doctolib.com>
The worklet path hands the remend import to a worklet runtime, which needs
Bundle Mode importForwarding. That option first exists in
react-native-worklets 0.10, so apps pinned lower, for example through
react-native-reanimated 4.3.x, hit a runtime error on the first streamed token.

Everything else the worklet path needs already exists in 0.8.3, so let callers
opt into the JS-thread branch this file already has for web. forceJsThread joins
the existing web condition rather than adding a second branch.

The default path, the module-scope runtime and the peer range are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Arnaud Zheng <arnaud.zheng@doctolib.com>
Co-Authored-By: Bacary Bruno Bodian <brunobacary.bodian@doctolib.com>
Both reasons to skip the worklet runtime now read as one named idea rather than
an inline boolean. Behaviour is identical and the tests from the first commit
still pass untouched.

Also documents the prop in the README, including how to install past the peer
warning on older worklets, and adds a toggle to the example app so the two
paths can be compared in a single build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Arnaud Zheng <arnaud.zheng@doctolib.com>
Co-Authored-By: Bacary Bruno Bodian <brunobacary.bodian@doctolib.com>
@arnaud-zg
arnaud-zg force-pushed the feat/force-js-thread branch 2 times, most recently from 75634f5 to dafbd9b Compare September 22, 2026 14:01
@arnaud-zg
arnaud-zg marked this pull request as ready for review September 25, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant