@eclosion-tech/react-native-yjs-text
v0.1.0
Published
Native React Native rich text editor backed by Y.Text — no WebView, no contenteditable
Readme
@eclosion-tech/react-native-yjs-text
Native React Native rich text editor backed by Y.Text. No WebView, no
contenteditable, no DOM.
Built as an Expo Module — works in any Expo SDK 52+ project (and in bare RN
projects via expo-modules-core).
Status: v0.1.0 — alpha. The TypeScript layer, Swift iOS view, and Kotlin Android view all compile cleanly. Both example apps (Expo-managed in
example/and bare RN inexample-bare/) link the library and produce a working.app/.apk. A 50-case Jest suite covers the bridge, schema, and editor hook. The remaining v0.1 work is on-device behavioural verification (typing, mark toggling, caret stability under remote edits).
Why this exists
Every existing rich-text editor in React Native today wraps a browser editor
in a WebView: react-native-pell-rich-editor, @10play/tentap-editor,
react-native-cn-quill. They all pay the WebView tax — worse performance,
broken keyboard behaviour, gesture / scroll conflicts, accessibility
regressions, platform-specific bugs that don't reproduce in browsers.
Y.Text is already a platform-independent rich-text primitive — a sequence of
characters with arbitrary formatting attributes. That maps cleanly onto
contenteditable (via y-prosemirror on the web) and onto
NSAttributedString / Spannable on native mobile. This library is the
native-view side of that binding.
See SPEC.md for the full design rationale, non-goals, and
versioning roadmap.
What ships in v0.1
YTextInput— editable view backed byUITextView(iOS) /AppCompatEditText(Android)YTextRenderer— read-only renderer that uses RN's native<Text>with attributed spans (noUITextViewinstance, much cheaper)useYTextEditor— imperative editor hook (toggleMark,setMark,removeMark,insertText,deleteRange,focus,blur,getSelection,setSelection,marksAtSelection)defaultSchema—bold,italic,underline,strike,code,link(matching the y-prosemirror mark convention so the sameY.Textcontent edits identically on web)- Bidirectional sync between user edits and
Y.Textmutations, withY.RelativePosition-based caret preservation across remote insertions
Install
Expo (managed or prebuild)
npx expo install @eclosion-tech/react-native-yjs-text yjs isomorphic-webcrypto
npx expo prebuild # if you don't already have native projects
npx expo run:ios # or run:androidExpo Go can't load the library (native code); you need a dev build.
Why
isomorphic-webcrypto? yjs's underlying utility liblib0generates random doc / client IDs viacrypto.getRandomValues. React Native doesn't expose webcrypto by default, solib0's RN entry-point hard-requiresisomorphic-webcryptoand expects the consumer to install it. (You can alternatively installreact-native-get-random-valuesandimport 'react-native-get-random-values'before the first yjs import — thenlib0skips its webcrypto shim and uses the polyfilled native one. Either works; pick whichever your app already has.)
Bare React Native (RNCLI scaffold, brownfield, etc.)
The library uses the Expo Modules API for its native side, so a bare RN host needs Expo Modules autolinking. This does not turn your app into "an Expo app" — no app shell, no Expo Go runtime, no expo-router. You're only opting into native autolinking.
npm install @eclosion-tech/react-native-yjs-text yjs expo isomorphic-webcrypto
# expo brings in expo-modules-core + the autolinking scripts; nothing else
# isomorphic-webcrypto is required by yjs's lib0 dep — see the Expo section
# above for the alternative `react-native-get-random-values` routeThen three small wiring changes:
ios/Podfile— load Expo's autolinking + calluse_expo_modules!:require File.join( File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking" ) platform :ios, '16.4' # expo-modules-core requires 16.4+ target 'YourApp' do use_expo_modules! config = use_native_modules! # ...rest of the standard RN Podfile target... endios/YourApp/AppDelegate.swift— subclass Expo's app-delegate / factory wrappers so Expo Modules register at boot:internal import Expo // matches the access level of the generated // ExpoModulesProvider.swift import React import ReactAppDependencyProvider @main class AppDelegate: ExpoAppDelegate { var window: UIWindow? var reactNativeDelegate: ExpoReactNativeFactoryDelegate? var reactNativeFactory: RCTReactNativeFactory? public override func application(/* ... */) -> Bool { let delegate = ReactNativeDelegate() let factory = ExpoReactNativeFactory(delegate: delegate) delegate.dependencyProvider = RCTAppDependencyProvider() reactNativeDelegate = delegate reactNativeFactory = factory window = UIWindow(frame: UIScreen.main.bounds) factory.startReactNative(withModuleName: "YourApp", in: window, launchOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { /* ... */ }android/settings.gradle— pull in the Expo autolinking gradle plugin and callexpoAutolinking.useExpoModules().android/app/ src/main/java/.../MainApplication.kt— replaceDefaultReactHost.getDefaultReactHost(...)withExpoReactHostFactory.getDefaultReactHost(...)and addApplicationLifecycleDispatcher.onApplicationCreate(this)/onConfigurationChanged(this, newConfig).
The complete working set of changes for a fresh @react-native-community/
cli init project lives in example-bare/ — copy from
there if you'd rather diff against a known-good reference than apply the
snippets above by hand.
Usage
import {
YTextInput,
YTextRenderer,
defaultSchema,
useYTextEditor,
} from '@eclosion-tech/react-native-yjs-text';
import * as Y from 'yjs';
const doc = new Y.Doc();
const yText = doc.getText('content');
function Editor() {
const editor = useYTextEditor(yText, defaultSchema);
return (
<>
<Toolbar editor={editor} />
<YTextInput
yText={yText}
schema={defaultSchema}
style={{ fontSize: 16, color: '#111' }}
placeholder="Start typing…"
/>
<YTextRenderer yText={yText} schema={defaultSchema} />
</>
);
}
function Toolbar({ editor }) {
return (
<View>
<Button onPress={() => editor.toggleMark('bold')} title="B" />
<Button onPress={() => editor.toggleMark('italic')} title="I" />
<Button
onPress={() => editor.setMark('link', { href: 'https://yjs.dev' })}
title="Link"
/>
</View>
);
}The library does not own the Y.Doc / Y.Text — the consumer creates,
syncs (via y-websocket, y-indexeddb, a custom provider, whatever), and
disposes of them. The component subscribes to a passed-in Y.Text and edits
it in place.
See example/App.tsx for a full demo that exercises
every default mark, programmatic mutations, and YTextRenderer.
Schemas
A schema declares which marks the editor accepts. Marks not in the schema
are dropped on insert — this is what makes the editor safe for AI-generated
content. Custom marks are declared by extending defaultSchema:
import { defaultSchema, type Schema } from '@eclosion-tech/react-native-yjs-text';
const schema: Schema = {
marks: {
...defaultSchema.marks,
'pear.mention': {
attrs: { userId: { type: 'string', required: true } },
renderStyle: { color: '#0066cc', backgroundColor: '#e6f0ff' },
onTap: (attrs) => console.log('mention tapped', attrs.userId),
},
},
};Only a subset of RN's TextStyle survives the bridge into native rendering:
fontWeight, fontStyle, fontFamily, fontSize, color,
backgroundColor, textDecorationLine. Keys outside that list are silently
dropped (a deliberate cross-platform-safety choice — see
RENDERABLE_TEXT_STYLE_KEYS in src/schema.ts).
Building & running
This repo is a pnpm workspace with three members:
.— the libraryexample/— Expo SDK 56 demo (usesexpo run:ios/expo run:android)example-bare/— vanilla@react-native-community/clidemo (no Expo runtime around the host app, only Expo Modules autolinking on the native side)
# From the repo root
pnpm install # installs all three workspace members
pnpm prepare # builds the library's TS into `build/`
pnpm test # runs the Jest suite (50 tests, ~1s)
# Expo example
cd example
npx pod-install # or: cd ios && pod install
npx expo run:ios # or: npx expo run:android
# Bare RN example
cd example-bare
(cd ios && pod install)
npx react-native run-ios # or: npx react-native run-androidThe bare example doubles as the canonical integration test for non-Expo
hosts; if it boots, your @react-native-community/cli init-shaped project
will too.
Architecture
┌─────────────────────────────────────────┐
│ Consumer application │
│ - manages the Y.Doc / Y.Text │
│ - builds toolbar / slash menu UI │
│ - composes multiple YTextInputs into │
│ its own block model │
└──────────────┬──────────────────────────┘
│ <YTextInput yText={...} schema={...} />
│
┌──────────────▼──────────────────────────┐
│ react-native-yjs-text (TS layer) │
│ - YTextInput / YTextRenderer │
│ - useYTextEditor command API │
│ - schema definitions / mark validation│
│ - Y.Text ↔ runs ↔ native event bridge │
└──────────────┬──────────────────────────┘
│ Expo Modules (Fabric / TurboModules)
│
┌──────────────▼──────────┬───────────────┐
│ iOS native │ Android native│
│ - YjsTextView (ExpoView │ - YjsTextView │
│ + UITextView subview) │ (ExpoView + │
│ - NSAttributedString │ AppCompatEditText)
│ - UITextViewDelegate │ - SpannableStringBuilder
│ shouldChangeTextIn │ - TextWatcher │
└─────────────────────────┴───────────────┘Known gaps in v0.1
Per the spec (SPEC.md § Versioning roadmap):
- IME composition for CJK / Korean / IMEs with multi-stage input. We
hand the composition string straight through to
Y.Texton every keystroke in the composition session, which collaborators see as a flurry of character-by-character inserts and deletes instead of one atomic composition commit. A "compose locally, transact on commit" path lands in v0.2 once we addUITextInput.markedTextRange/ AndroidInputConnection.setComposingTexthandling. - Paste from system clipboard loses formatting — content is inserted as plain runs.
- Hardware-keyboard shortcuts (⌘B / Ctrl+B etc.) are not bound by default; consumers can wire their own via the imperative editor API.
- Bridge traffic goes through the standard Expo Modules ABI on every
edit. v0.2 will move the typing-hot-path event (
onContentChange) to a JSI direct call to skip JSON ser/de per keystroke. - No first-party block model. Each
YTextInputedits one inline region; the consumer composes them into paragraphs / headings / lists. SeeSPEC.mdfor why this is by design. - Selection highlight is visually masked by
backgroundColormarks. BothUITextView(iOS) andEditText(Android) drawNSAttributedString. backgroundColor/BackgroundColorSpanon top of the system selection highlight, so selecting text that already has, e.g., the defaultcodemark applied makes the selection rectangle invisible inside the marked range. The selection is still active functionally — toolbar toggles and keyboard actions work as normal — only the visual rectangle is occluded. iOS users still see the two grab-handle circles. Mirrors how Mobile Safari, iOS Notes, and most platform editors render the same situation.
License
MIT. See LICENSE.
