A React Native code editor component that embeds CodeMirror 6 inside a <WebView> and
maintains a full bidirectional RPC bridge between React Native and the web page using
@actualwave/webview-interface (DDA-based).
react-native-codeditor/ ← library root (also workspace root)
src/
index.tsx Re-exports everything public
CodeEditor.tsx Main component (hooks-based)
BlockingView.tsx Semi-transparent overlay shown until editor is ready
WebViewAPI.ts RN-side bridge; wraps initializeHost + DDA proxy to page
EditorAPI.ts TypeScript types: EditorAPI, ExtensionSpec, HistorySize, …
assets/
editor.html WebView guest page: DDA bootstrap + CM6 editor init
webview-interface.umd.js Self-contained UMD bundle (webview-interface + DDA)
codemirror-editor.umd.js Plain IIFE bundle of requireAsyncModule + createEditor
codemirror/ CM6 module files (from codemirror-package dist/)
_core.js Bundled core: state, view, language, commands, autocomplete…
@codemirror_lang-*.js Language support files (loaded lazily on demand)
@uiw_codemirror-theme-*.js Per-theme files (loaded lazily on demand)
… (all other CM6 deps)
example/
src/App.tsx Demo app — language/theme switcher, undo/redo toolbar
lib/ Build output (react-native-builder-bob)
module/ ESM, consumed by Metro
typescript/ Type declarations
react-native.config.js Declares src/assets for react-native link asset copying
Two separate runtime environments communicate via the DDA (Deferred Data Access) RPC layer, transported over the asymmetric WebView message channels:
React Native (HOST) WebView (GUEST — editor.html)
────────────────────────────────── ────────────────────────────────────────
CodeEditor (React component)
└─ WebViewAPI
└─ initializeHost() ◄──► WebViewInterface.initializeGuest()
hostRoot proxy editorTarget (EditorController methods)
├─ getInitialConfig() ↕ DDA RPC over:
├─ onLog() ←── ReactNativeWebView.postMessage (GUEST→HOST)
└─ onError() ──► injectJavaScript (HOST→GUEST)
window.dispatchEvent(MessageEvent)
out-of-band _rnPost:
__contentChange__ ←── ReactNativeWebView.postMessage (one-way)
__editorReady__ ←──
__editorLog__ ←──
__editorError__ ←──
initializeHost uses webView.injectJavaScript(...) to dispatch a MessageEvent on window.
The in-page DDA receiver picks it up.
The page calls window.ReactNativeWebView.postMessage(JSON.stringify(msg)). The <WebView onMessage> handler forwards the raw event to api.onMessage(event) which feeds the HOST's
DDA subscriber.
WebViewAPI.onMessage intercepts these JSON packets before forwarding to DDA:
| type | Direction | Meaning |
|---|---|---|
__editorLog__ |
GUEST→HOST | window.log(...) calls from the WebView |
__editorError__ |
GUEST→HOST | window.onerror / unhandledrejection |
__editorReady__ |
GUEST→HOST | Editor fully ready; HOST fires onInitialized |
__contentChange__ |
GUEST→HOST | Content/history update on every debounced keystroke |
__contentChange__ carries { value, undo, redo }. It is a one-way fire-and-forget message
(no DDA round trip, no injectJavaScript response). This keeps all injectJavaScript calls
away from the typing hot path, preventing interference with Android IME composition.
All other messages are DDA command/response envelopes. The DDA layer handles serialisation, routing, and Promise resolution transparently on both sides.
1. Component mounts
→ WebViewAPI constructed (bridge not yet active)
→ CodeEditor renders <WebView source={{ uri: editorUri }} />
2. WebView loads editor.html from file:///android_asset/codeditor/editor.html
→ <script src="./webview-interface.umd.js"> → defines window.WebViewInterface
→ <script src="./codemirror-editor.umd.js"> → defines window.CodeMirrorEditor
→ async IIFE begins executing
3. GUEST bootstrap runs:
→ configure({ loader: xhrLoader }) ← XHR-based for file:// compatibility
→ WebViewInterface.initializeGuest({ root: editorTarget, handshakeTimeout: 30000 })
starts sending DDA handshake pings via ReactNativeWebView.postMessage
4. HOST (CodeEditor) wires onMessage before WebView mounts:
→ initializeHost({ webView, root: hostRoot }) returns { onMessage, connection }
→ onMessage assigned to <WebView onMessage> prop immediately
5. HOST receives first GUEST ping → sends handshake response via injectJavaScript
→ DDA handshake completes on both sides
6. GUEST receives handshake response:
→ nativeApi proxy available (calls route to hostRoot methods)
→ config = await nativeApi.getInitialConfig()
→ editor = await createEditor({ doc, language, extensions, onChange })
— loads _core.js (861 KB) via XHR, then language + theme files on demand
→ Object.assign(editorTarget, { all real methods })
→ applies config.viewport if provided
→ posts __editorReady__
7. HOST receives __editorReady__:
→ handlers.onInitialized(this) called — caller receives WebViewAPI handle
→ BlockingView disappears (editor is painted with correct theme)
8. Ongoing: onChange fires in GUEST on every keystroke (debounced 300ms)
→ _rnPost('__contentChange__', { value, undo, redo }) — out-of-band, no DDA
→ HOST.onMessage intercepts → calls onContentUpdate(value) + onHistorySizeUpdate({ undo, redo })
Why __editorReady__ instead of DDA connection: connection.then resolves at step 5,
before the editor is visually ready. Delaying onInitialized to __editorReady__ ensures
the BlockingView only disappears when the editor is painted with its theme.
Initial editor settings are delivered to the GUEST via nativeApi.getInitialConfig(), which
the GUEST calls as its first DDA request. This includes:
interface InitialConfig {
content?: string;
language?: string;
extensions?: ExtensionSpec[];
theme?: string;
viewport?: ViewportSettings;
}viewport is in InitialConfig (not a separate DDA call) because setViewport requires
pageApi to be ready — calling it before the handshake completes would throw.
The codemirror module system uses the requireAsyncModule loader from codemirror-package,
bundled into codemirror-editor.umd.js (plain IIFE, no ES module syntax).
_core.js— one XHR loads all 14 core packages (state, view, language, commands, etc.) and populates a sharedmoduleCache. All core modules share a single request.- All other
.jsfiles — fetched individually on firstrequireAsyncModule(packageName)call. Results are cached; repeated calls return synchronously from cache. - The loader uses
XMLHttpRequest(notfetch) because Android WebView blocksfetch()forfile://origins even when same-origin. XHRstatus === 0means success forfile://.
Each module file uses the moduleInitFunction pattern:
async function moduleInitFunction(requireAsyncModule, exports={}) { ... }Loaded via XHR, executed via eval(code + ';moduleInitFunction') — the ;moduleInitFunction
suffix retrieves the function reference from strict-mode eval scope.
Codemirror version: 6.x. All modules sourced from @actualwave/codemirror-package
(devDependency). npm run copy-assets (or npm run prepare) regenerates them from
node_modules/@actualwave/codemirror-package/dist/.
The WebView is fully offline. No CDN or internet access required.
Each @uiw theme lives in its own package, not the meta-package @uiw/codemirror-themes
(which only exports createTheme). The correct spec form is:
['@uiw/codemirror-theme-darcula', 'darcula'] // ✓ correct
['@uiw/codemirror-themes', 'darcula'] // ✗ wrong — returns undefinedMost themes export under a name matching the theme, but two use "Dark" variants:
| Theme name | Package | Export name |
|---|---|---|
github |
@uiw/codemirror-theme-github |
githubDark |
vscode |
@uiw/codemirror-theme-vscode |
vscodeDark |
| all others | @uiw/codemirror-theme-{name} |
{name} |
In editor.html, getThemeSpec(themeName) encodes this mapping. In editorTarget.setTheme
the same function is used. Do not change to use @uiw/codemirror-themes directly.
A plain IIFE bundle generated by scripts/copy-assets.js from
node_modules/@actualwave/codemirror-package/dist/requireAsyncModule.js +
node_modules/@actualwave/codemirror-package/dist/index.js. All export/import
keywords are stripped by the script. Do not edit the file directly — run npm run copy-assets.
Why not ES modules: <script type="module"> in Android WebView causes silent failures when
imported files are missing, making debugging extremely difficult. A plain <script> tag with
an IIFE shows errors via window.onerror instead of silently aborting.
Exposes: window.CodeMirrorEditor = { configure, createEditor, requireAsyncModule, registerExtension }
createEditor uses a custom mobileSetup instead of CM6's basicSetup. The only
difference is that drawSelection() is omitted — it replaces the native browser cursor
with a custom overlay which breaks Android IME composition tracking (causes ghost text
and cursor not advancing when typing fast). All other basicSetup extensions are present.
foldKeymap is sourced from @codemirror/language (not @codemirror/commands), which
is where CM6 actually exports it.
EditorView.EDIT_CONTEXT = false is set inside createEditor (in codemirror-package/index.js)
immediately after the Promise.all destructuring resolves, before new EditorView() is called.
Chrome 126+ WebView uses the EditContext API for IME; Chrome 147 has a race condition where
successive textupdate events arrive faster than CM6 can sync back via
editContext.updateText/updateSelection, causing characters to appear after the cursor during
fast typing. Setting EDIT_CONTEXT = false makes CM6 fall back to the contenteditable +
MutationObserver path, which is stable when drawSelection() is omitted (native cursor stays
visible for the IME to track). The flag must be set on the real EditorView class extracted
from the Promise.all result — setting it on the module stub before _core.js loads would be
overwritten by Object.assign(stub, actualExports) when loading completes.
expo prebuild does NOT copy react-native.config.js assets from workspace packages.
The assets field in react-native.config.js is only processed by npx react-native link
(bare RN CLI). With Expo you must copy assets manually or via a config plugin.
Assets must land in a codeditor/ subfolder on both platforms:
# Android
mkdir -p example/android/app/src/main/assets/codeditor
cp -r src/assets/* example/android/app/src/main/assets/codeditor/
# iOS
mkdir -p example/ios/CodeditorExample/assets/codeditor
cp -r src/assets/* example/ios/CodeditorExample/assets/codeditor/Copying files to disk is not enough on iOS — Xcode must have a folder reference in
project.pbxproj pointing to CodeditorExample/assets (path relative to ios/).
The example/copy-assets-plugin.js handles this via withXcodeProject during
expo prebuild. See COPY_ASSETS.md for the full manual Xcode steps.
The default editorUri is Android-only. For iOS, compute the bundle path at runtime:
import * as FileSystem from 'expo-file-system';
const IOS_EDITOR_URI = Platform.OS === 'ios'
? (FileSystem.bundleDirectory ?? '') + 'assets/codeditor/editor.html'
: undefined;
<CodeEditor editorUri={IOS_EDITOR_URI} ... />expo-file-system must be installed (npm install expo-file-system) and its iOS pod
linked (pod install). It is already present in example/ (ExpoFileSystem in
Podfile.lock).
example/copy-assets-plugin.js automates the copy + Xcode project edit during
expo prebuild. Add it to app.json plugins to avoid manual steps on future prebuilds.
See COPY_ASSETS.md for detailed documentation.
DDA operates in lazy mode by default. A proxy method call (e.g. api.editor.historyUndo())
builds a command chain but does NOT dispatch it until .then is accessed on the returned
value — i.e. until the call is await-ed. Discarding the result with void or ignoring it
entirely silently does nothing; no command is ever sent to the WebView.
Every call to api.editor.* must be awaited. Example patterns:
// ✓ correct — dispatches the command
await apiRef.current?.editor?.historyUndo();
// ✓ also correct — fire and forget inside useEffect
void (async () => { await apiRef.current!.editor.setLanguage(language); })();
// ✗ wrong — command is never sent
void apiRef.current?.editor.historyUndo();
apiRef.current?.editor.historyUndo();Each prop change is guarded by a prevRef that starts as null — the first render is
always skipped, preventing redundant pushes when the editor is already initialized with
the correct initial config. Effects also check initializedRef.current before calling API
methods, so they are no-ops until the editor is ready.
| Prop changed | Action |
|---|---|
content |
api.editor.setValue(content) if value differs from currentContentRef |
language |
api.editor.setLanguage(language) |
theme |
api.editor.setTheme(theme) |
extensions |
api.editor.setExtensions(extensions) |
viewport |
api.editor.setViewport(viewport) |
Each effect wraps the DDA call in void (async () => { await ... })() — useEffect callbacks
must be synchronous, so the async IIFE pattern is used to enable await inside them.
Initial values are delivered once during handshake via nativeApi.getInitialConfig() —
not re-pushed as prop-change effects.
| Prop | Type | Default | Notes |
|---|---|---|---|
onInitialized |
(api: WebViewAPI) => void |
required | Fires when editor is fully ready (theme + language loaded). Call api.focus() here to show Android keyboard. |
onHistorySizeUpdate |
(size: HistorySize) => void |
required | Fired on every edit; HistorySize = { undo, redo } |
onLog |
(...args) => void |
required | window.log(...) calls from inside the WebView |
onError |
(error) => void |
required | Errors from inside the WebView |
onContentUpdate |
(content: string) => void |
required | Fired on every keystroke (CM6 onChange) |
language |
string |
undefined |
CM6 language name, e.g. 'javascript', 'python' |
extensions |
ExtensionSpec[] |
[] |
CM6 extension specs — see ExtensionSpec below |
theme |
string |
undefined |
Theme name, e.g. 'darcula', 'github'. Loaded from per-theme package. |
content |
string |
'' |
Controlled document content |
viewport |
ViewportSettings |
undefined |
Viewport meta scaling |
allowFileAccess |
boolean |
true |
WebView prop; required for file:// asset loading |
editorUri |
string |
'file:///android_asset/codeditor/editor.html' |
Override for iOS — use expo-file-system bundleDirectory (see iOS notes below) |
renderBlockingView |
() => ReactNode |
() => <BlockingView /> |
Loading overlay |
onWebViewRefUpdated |
(ref) => void |
— | Called when internal WebView ref changes |
onLoad/Start/Progress/End |
func | — | WebView event pass-throughs |
onNavigationStateChange |
func | — | WebView event pass-through |
type ExtensionSpec =
| string // package name → resolved via built-in registry
| [string, string] // [packageName, exportName] → mod[exportName]
| [string, object] // [packageName, options] → resolver(mod, options)
| unknown // pre-built CM6 Extension, used as-isandroidstudio andromeda atomone aura basic bbedit copilot darcula
dracula duotone eclipse github material monokai nord okaidia
solarized sublime vscode xcode
WebViewAPI is returned in onInitialized. It wraps the DDA proxy to the GUEST-side
EditorController and adds RN-side side-effects (requestFocus, injectJavaScript).
All methods except injectJavaScript and requestFocus are DDA calls — they return
Promises resolved by the GUEST.
// Content
api.getValue() → Promise<string>
api.setValue(value) → Promise<void>
api.resetValue(value?) → Promise<void> (setValue + historyClear)
// Language / extensions / theme
api.setLanguage(name) → Promise<void> (loads lang module on demand)
api.setExtensions(specs) → Promise<void> (replaces extension compartment)
api.setTheme(themeName?) → Promise<void> (loads from per-theme package)
// Viewport
api.setViewport(options) → Promise<void> (updates <meta name="viewport">)
// Focus
api.focus() → Promise<void> (+ calls webView.requestFocus())
// Cursor / selection
api.getCursor(where?) → Promise<CursorPosition> // { line, ch, index }
api.setCursor(line, ch?) → Promise<void>
api.getSelection() → Promise<string>
api.setSelection(anchor, head?) → Promise<void>
api.replaceSelection(text) → Promise<void>
api.cancelSelection() → Promise<void>
// History
api.historyUndo() → Promise<boolean>
api.historyRedo() → Promise<boolean>
api.historyClear() → Promise<void>
api.historySize() → Promise<HistorySize> // { undo, redo }
// Scroll
api.scrollToCursor(margin?) → Promise<void>
// Advanced
api.loadExtension(moduleName) → Promise<object> (raw module exports)
api.destroy() → Promise<void>
// RN-side only (synchronous, no DDA)
api.injectJavaScript(code) → void
api.requestFocus() → voidandroid:windowSoftInputMode="adjustResize" is set in AndroidManifest.xml. Wrap the
screen in KeyboardAvoidingView to make the editor shrink when the keyboard appears:
import { KeyboardAvoidingView, Platform } from 'react-native';
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<CodeEditor ... />
</KeyboardAvoidingView>Call api.focus() inside onInitialized to trigger the Android soft keyboard.
| Package | Where | Why |
|---|---|---|
@actualwave/webview-interface |
dependencies (npm) |
HOST/GUEST DDA bridge over injectJavaScript / postMessage |
@actualwave/deferred-data-access |
dependencies (npm) |
DDA core (transitive via webview-interface) |
@actualwave/weak-storage |
dependencies (npm) |
Transitive dep of DDA |
react-native-webview |
peerDependencies (≥11) |
The actual WebView component |
react, react-native |
peerDependencies |
— |
No @actualwave/messageport-dispatcher — replaced by webview-interface.
No CDN dependencies — CM6 served from bundled assets.
When any @actualwave/* package is updated on npm:
# 1. Update version(s) in package.json, then:
npm install
# 2. Regenerate all assets and rebuild:
npm run prepare
cp -r src/assets/* example/android/app/src/main/assets/codeditor/
cp -r src/assets/* example/ios/CodeditorExample/assets/codeditor/
# 3. Run:
cd example && npx expo run:android # or run:iosWhen updating @actualwave/js-codemirror-package specifically, npm run copy-assets
(run automatically by npm run prepare) regenerates the WebView assets from the
published dist/ — no manual node start.js needed since the package is pre-built.
Hermes does not expose FinalizationRegistry as a global.
Fix applied in @actualwave/weak-storage: WeakValueMap constructor now accepts an
optional registry class as its second argument. Callers can pass null/undefined to disable
auto-cleanup.
Fix applied in @actualwave/deferred-data-access: ResourcePool passes
FinalizationRegistry (which is undefined in Hermes, triggering the graceful fallback).
@actualwave/webview-interface (CJS) requires @actualwave/deferred-data-access/interface.
Metro falls through to the ESM source files when a subpackage package.json has no main
field. Babel's CJS transform of ESM export * chains creates getter-based live bindings
that break React Fast Refresh:
ReferenceError: Property 'IdOwner' doesn't exist
Fix applied in @actualwave/deferred-data-access: All importable subpackages now have
CJS bundles and "main" fields (interface.js, proxy.js, resource.js, etc.).
DDA's getMessageEventData used event instanceof Event. Event is a DOM global absent
in Hermes.
Fix applied in @actualwave/deferred-data-access (interface/src/utils.ts):
// Before (DOM-only):
event instanceof Event ? event.data : event
// After (structural check, works everywhere):
event != null && typeof event === 'object' && 'data' in event ? event.data : event# Install dependencies (postinstall runs copy-assets automatically)
npm install
# Apply the iOS Swift patch (development only — not needed for Android)
npx patch-package
# Build lib/ from src/
npm run prepare
# Or regenerate only the WebView assets without rebuilding lib/:
npm run copy-assets
# Type-check
npx tsc --noEmit
# Copy assets to example projects (expo prebuild does NOT do this automatically)
mkdir -p example/android/app/src/main/assets/codeditor
cp -r src/assets/* example/android/app/src/main/assets/codeditor/
mkdir -p example/ios/CodeditorExample/assets/codeditor
cp -r src/assets/* example/ios/CodeditorExample/assets/codeditor/
# Run example on Android or iOS
cd example && npx expo run:android # or run:iosMetro resolves @actualwave/react-native-codeditor → workspace root → lib/module/index.js.
After any source change, run npm run prepare before reloading the app.
Do NOT use ./gradlew clean — it fails because react-native-webview codegen JNI
directory doesn't exist until after first build. Use npx expo run:android directly.
-
_core.jscold-start cost — The core bundle is 861 KB. First load downloads andeval()s it synchronously on the DDA-resolved Promise chain. On slow devices the initial editor appearance may take 1–2 seconds after the WebView loads. -
No
forwardRef— Theapihandle is delivered viaonInitialized(api). -
No tests —
WebViewAPIandCodeEditorrequire a React Native + WebView mock environment. -
Updating codemirror assets — Update
@actualwave/js-codemirror-packageinpackage.json, runnpm install, thennpm run copy-assets. The package is pre-built on npm so no manualnode start.jsis needed. -
Initial theme flash —
onInitializedfires aftercreateEditorcompletes (via__editorReady__), so the BlockingView disappears only when the editor is painted. However there may still be a brief flash on very slow devices if the WebView renders beforecreateEditorfinishes. No known fix yet.
Migrated from the old project (plain JS, Rollup, CodeMirror 5) to TypeScript + react-native-builder-bob + Expo example.
Rebuilt to use CodeMirror 6 and the DDA-based bridge:
- CM5 CDN → CM6 from
codemirror-packagedist (offline, bundled assets) MessagePortDispatcher+MessagePortDummy→@actualwave/webview-interface- Manual request/response event system → DDA proxy (deleted)
modules[]+settings{}props →language+extensions[]propsautoUpdateIntervalpolling → CM6onChangecallback (real-time)- Theme: CM5 theme name (CDN CSS) →
@uiw/codemirror-themesextension (offline)
Resolved all startup failures and made the editor work end-to-end:
- Replaced
<script type="module">with plain IIFE — silent ES module failures in WebView made debugging impossible;window.onerrorcatches errors from plain scripts - Created
codemirror-editor.umd.js— inlinesrequireAsyncModule.js+index.jsfrom codemirror-package as a plain IIFE (noimport/export) - Fixed
@actualwave/deferred-data-access:FinalizationRegistryfallback, CJS subpackage bundles,getMessageEventDatausing structural check instead ofinstanceof Event - Added out-of-band message protocol (
__editorLog__,__editorError__,__editorReady__) - Moved
onInitializedtrigger to__editorReady__(fires after editor is fully painted) - Fixed theme loading to use per-package specs (
@uiw/codemirror-theme-{name})
@actualwave/codemirror-package added as a devDependency. scripts/copy-assets.js now
generates both src/assets/codemirror-editor.umd.js and src/assets/codemirror/ from
node_modules/@actualwave/codemirror-package/dist/ automatically. npm run prepare
runs the script before bob build, so assets are always in sync with the package version.
Made the editor work end-to-end on iOS:
- Fixed Ruby 4.0 / CocoaPods incompatibility — switched to Ruby 3.3 via Homebrew
- Fixed expo-modules-core 55.0.25 Swift 6 build errors:
SWIFT_STRICT_CONCURRENCY = minimalin Podfile +SWIFT_VERSION = '5'for the three Expo targets that default to Swift 6; patch-package removes invalid@MainActorconformance syntax from 3 source files - Fixed iOS WebView "Error loading page": assets now land in
ios/CodeditorExample/assets/codeditor/with a folder reference added toproject.pbxproj(path = CodeditorExample/assets,lastKnownFileType = folder) so Xcode includes them in Copy Bundle Resources editorUrifor iOS:FileSystem.bundleDirectory + 'assets/codeditor/editor.html'viaexpo-file-system; passesundefinedon Android so the default URI is usedexample/copy-assets-plugin.jsextended withwithXcodeProjectto automate the Xcode folder reference on futureexpo prebuildruns- Asset destination changed to
codeditor/subfolder on both platforms (Android URI updated tofile:///android_asset/codeditor/editor.html) - Full details in
FIX_IOS_NOTES.mdandCOPY_ASSETS.md
Resolved Android typing issues and silent DDA call failures:
mobileSetup: replacedbasicSetupwith custom setup that omitsdrawSelection()— this fixes Android IME ghost text (drawSelection hides the native cursor the IME tracks)foldKeymapmoved to correct import (@codemirror/language, not@codemirror/commands)__contentChange__out-of-band message: replaced DDAnativeApi.onContentChange()call with_rnPost('__contentChange__', ...)— eliminates allinjectJavaScriptcalls during typing, preventing IME composition disruption; added 300ms debounce- All
api.editor.*calls inApp.tsxandCodeEditor.tsxeffects changed to properlyawait— DDA lazy mode silently drops commands that are never awaited (void xdoes not trigger.thenand therefore never dispatches the command to the WebView) EditorView.EDIT_CONTEXT = falseset increateEditor(afterPromise.allresolves, beforenew EditorView()) — disables Chrome 126+ EditContext API which has a race condition in Chrome 147 WebView during fast typing (characters appearing after cursor); falls back to contenteditable + MutationObserver path which is stable withoutdrawSelection()