react-native-shiki-engine
v0.4.0
Published
Shiki syntax highlighting for React Native. Zero bridge overhead with native Oniguruma regex engine.
Maintainers
Readme
React Native Shiki Engine
Oniguruma regex engine implementation for React Native, providing high-performance syntax highlighting capabilities through Shiki. This module implements a JSI-based native bridge to Oniguruma, enabling efficient pattern matching and syntax highlighting in React Native applications.
[!IMPORTANT] React Native Shiki Engine requires the New Architecture to be enabled (react-native 0.73+)
Features
- High-performance native regex engine using Oniguruma, optimized for syntax highlighting
- Fully synchronous pattern matching with no async/await, no Promises, no Bridge
- Uses JSI and C++ TurboModules for direct JavaScript-to-native communication
- Smart pattern caching system for optimal performance
- Memory efficient with automatic cleanup of unused patterns
- Full compatibility with Shiki's regex engine requirements
- Written in modern C++ with robust memory management
- Multiple pattern support with efficient cache management
Installation
React Native
pnpm add react-native-shiki-engine @shikijs/core
cd ios && pod installYou'll also need to install the languages and themes you want to use:
pnpm add @shikijs/langs @shikijs/themesExpo
npx expo install react-native-shiki-engine @shikijs/core @shikijs/langs @shikijs/themes
npx expo prebuildFor web support in Expo, also install:
npx expo install @shikijs/engine-onigurumaUsage
Create a single highlighter at the app level with React Context. The examples use rust and dracula; the snippets below follow the same pattern as examples/react-native.
1. Define the context
src/contexts/highlighter/context.ts:
import type { ThemedToken } from '@shikijs/core'
import { createContext } from 'react'
export interface HighlighterContextType {
initialize: () => Promise<void>
tokenize: (code: string, options: { lang: string; theme: string }) => ThemedToken[][]
dispose: () => void
}
export const HighlighterContext = createContext<HighlighterContextType | null>(null)2. Create the provider
src/contexts/highlighter/index.tsx:
import type { HighlighterCore } from '@shikijs/core'
import { createHighlighterCore } from '@shikijs/core'
import rust from '@shikijs/langs/rust'
import dracula from '@shikijs/themes/dracula'
import React from 'react'
import { createNativeEngine, isNativeEngineAvailable } from 'react-native-shiki-engine'
import { HighlighterContext, type HighlighterContextType } from './context'
let highlighterInstance: HighlighterCore | null = null
let initializationPromise: Promise<void> | null = null
export function HighlighterProvider({ children }: { children: React.ReactNode }) {
const value = React.useMemo<HighlighterContextType>(
() => ({
initialize: async () => {
initializationPromise ??= (async () => {
if (!isNativeEngineAvailable()) throw new Error('Native engine not available.')
highlighterInstance = await createHighlighterCore({
langs: [rust],
themes: [dracula],
engine: createNativeEngine(),
})
})()
await initializationPromise
},
tokenize: (code, options) => {
if (!highlighterInstance)
throw new Error('Highlighter not initialized. Call initialize() first.')
return highlighterInstance.codeToTokensBase(code, options)
},
dispose: () => {
highlighterInstance?.dispose()
highlighterInstance = null
initializationPromise = null
},
}),
[],
)
return <HighlighterContext value={value}>{children}</HighlighterContext>
}3. Add a hook and use it in your app
src/hooks/useHighlighter.ts:
import { use } from 'react'
import { HighlighterContext } from '../contexts/highlighter/context'
export function useHighlighter() {
const context = use(HighlighterContext)
if (!context) throw new Error('useHighlighter must be used within a HighlighterProvider')
return context
}Wrap your app with the provider, initialize once, then tokenize:
import React, { useEffect, useState } from 'react'
import { Text } from 'react-native'
import { HighlighterProvider } from './contexts/highlighter'
import { useHighlighter } from './hooks/useHighlighter'
const code = `fn main() {
println!("Hello, world!");
}`
function HighlightedCode() {
const highlighter = useHighlighter()
const [output, setOutput] = useState('')
useEffect(() => {
const run = async () => {
await highlighter.initialize()
const tokens = highlighter.tokenize(code, { lang: 'rust', theme: 'dracula' })
setOutput(
tokens
.flat()
.map(token => token.content)
.join(''),
)
}
void run()
return () => highlighter.dispose()
}, [highlighter])
return <Text>{output}</Text>
}
export default function App() {
return (
<HighlighterProvider>
<HighlightedCode />
</HighlighterProvider>
)
}[!IMPORTANT]
Keep a single highlighter instance for the lifetime of your app. Do not create new highlighters inside render,
useEffect, or event handlers.
See the examples directory for full apps with token rendering and shared UI.
Advanced Configuration
The native engine exposes optional cache budgets and diagnostics:
const engine = createNativeEngine({
// Soft cap on compiled patterns in the process-wide cache
maxCacheEntries: 2000,
// Soft cap on estimated cache memory (bytes). When omitted, the native
// layer picks a budget from available device memory (capped at 50MiB).
maxMemoryBytes: 32 * 1024 * 1024,
})
engine.getCacheStats() // { entryCount, estimatedBytes, scannerCount, maxEntries, maxBytes }
engine.trimMemory() // drop unused cached patterns under pressure
engine.clearPatternCache() // drop unused entries now
engine.dispose() // destroy scanners created by this engine + clear unused cache
// Optional: trim on iOS memoryWarning + app backgrounding
import { attachMemoryPressureHandler } from 'react-native-shiki-engine'
const detach = attachMemoryPressureHandler(engine)
// later: detach()Pass options when creating the engine inside HighlighterProvider:
engine: createNativeEngine({ maxCacheEntries: 2000 }),Web Platform Support (Expo)
For Expo apps targeting web, the native engine is not available, it relies on TurboModules and JSI. Use Metro platform extensions (.web.tsx) to swap in the WASM engine on web.
The expo-app example uses this layout:
App.tsx # native entry
App.web.tsx # web entry (optional SafeAreaProvider wrapper)
src/
contexts/highlighter/
context.ts # shared context type (see examples/shared in the repo)
index.tsx # native HighlighterProvider
index.web.tsx # web HighlighterProvider
components/
ShikiExampleScreen.tsx # native screen
ShikiExampleScreen.web.tsx # web screen (no native engine check)Setup for Expo Web
- Install the WASM engine:
npx expo install @shikijs/engine-oniguruma- Enable the New Architecture for native builds in
app.json:
{
"expo": {
"newArchEnabled": true
}
}- Add the shared context type at
src/contexts/highlighter/context.ts:
import type { ThemedToken } from '@shikijs/core'
import { createContext } from 'react'
export interface HighlighterContextType {
initialize: () => Promise<void>
tokenize: (code: string, options: { lang: string; theme: string }) => ThemedToken[][]
dispose: () => void
}
export const HighlighterContext = createContext<HighlighterContextType | null>(null)- Create platform-specific providers:
Native — src/contexts/highlighter/index.tsx:
import type { HighlighterContextType } from './context'
import type { HighlighterCore } from '@shikijs/core'
import { HighlighterContext } from './context'
import { createHighlighterCore } from '@shikijs/core'
import rust from '@shikijs/langs/rust'
import dracula from '@shikijs/themes/dracula'
import React from 'react'
import { createNativeEngine, isNativeEngineAvailable } from 'react-native-shiki-engine'
let highlighterInstance: HighlighterCore | null = null
let initializationPromise: Promise<void> | null = null
export function HighlighterProvider({ children }: { children: React.ReactNode }) {
const value = React.useMemo<HighlighterContextType>(
() => ({
initialize: async () => {
initializationPromise ??= (async () => {
if (!isNativeEngineAvailable()) throw new Error('Native engine not available.')
highlighterInstance = await createHighlighterCore({
langs: [rust],
themes: [dracula],
engine: createNativeEngine(),
})
})()
await initializationPromise
},
tokenize: (code, options) => {
if (!highlighterInstance)
throw new Error('Highlighter not initialized. Call initialize() first.')
return highlighterInstance.codeToTokensBase(code, options)
},
dispose: () => {
highlighterInstance?.dispose()
highlighterInstance = null
initializationPromise = null
},
}),
[],
)
return <HighlighterContext value={value}>{children}</HighlighterContext>
}Web — src/contexts/highlighter/index.web.tsx:
import type { HighlighterContextType } from './context'
import type { HighlighterCore } from '@shikijs/core'
import { HighlighterContext } from './context'
import { createHighlighterCore } from '@shikijs/core'
import { createOnigurumaEngine } from '@shikijs/engine-oniguruma'
import rust from '@shikijs/langs/rust'
import dracula from '@shikijs/themes/dracula'
import React from 'react'
let highlighterInstance: HighlighterCore | null = null
let initializationPromise: Promise<void> | null = null
export function HighlighterProvider({ children }: { children: React.ReactNode }) {
const value = React.useMemo<HighlighterContextType>(
() => ({
initialize: async () => {
initializationPromise ??= (async () => {
highlighterInstance = await createHighlighterCore({
langs: [rust],
themes: [dracula],
engine: createOnigurumaEngine(import('@shikijs/engine-oniguruma/wasm-inlined')),
})
})()
await initializationPromise
},
tokenize: (code, options) => {
if (!highlighterInstance)
throw new Error('Highlighter not initialized. Call initialize() first.')
return highlighterInstance.codeToTokensBase(code, options)
},
dispose: () => {
highlighterInstance?.dispose()
highlighterInstance = null
initializationPromise = null
},
}),
[],
)
return <HighlighterContext value={value}>{children}</HighlighterContext>
}- Wrap your app at the project root (
App.tsx):
import { ShikiExampleScreen } from '@/components/ShikiExampleScreen'
import { HighlighterProvider } from '@/contexts/highlighter/index'
export default function App() {
return (
<HighlighterProvider>
<ShikiExampleScreen />
</HighlighterProvider>
)
}Metro picks index.web.tsx when bundling for web and index.tsx on native. The same import path works on both platforms.
See the expo-app example for the full app, including App.web.tsx and platform-specific screens.
Technical Architecture
Native Implementation
The module uses a three-layer architecture optimizing for both performance and developer experience:
JavaScript Layer (
src/)- TypeScript interfaces and JS wrapper for type safety
- Efficient pattern lifecycle management with automatic cleanup
- Seamless integration with Shiki's API
- Error boundaries with graceful degradation
C++ TurboModule (
cpp/)- JSI-backed TurboModule (New Architecture)
- Shared ownership of compiled patterns across scanners
- Thread-safe process-wide pattern cache
- UTF-16 index mapping with ASCII fast-path
Oniguruma Core (vendored)
- Native Oniguruma regex engine
- Multi-pattern search via leftmost
onig_searchover shared compiled patterns - UTF-8 matching with Unicode support
- Configurable match/retry limits on init
Pattern Caching
Compiled patterns live in a single process-wide cache, shared by every scanner:
- Patterns are keyed by source string and held with
shared_ptr - Scanners keep strong refs while alive; eviction only frees entries with no live scanners
- Size is an estimate (
4096 + patternLength * 32 + captures * 128), derived from public Oniguruma capture counts, not a measuredregex_theap footprint - Default memory budget is derived from available device memory (iOS
os_proc_available_memory/ Android/proc/meminfo), floored at 8MiB and capped at 50MiB - JS can override with
maxCacheEntries/maxMemoryBytes, and calltrimMemory/clearPatternCache/getCacheStats - iOS automatically trims on
UIApplicationDidReceiveMemoryWarning; apps can also callattachMemoryPressureHandler(engine)for AppState-driven trims (iOS memoryWarning + background)
Supported Platforms
| Platform | Architecture | Description | Status | | :-----------: | :----------: | :----------------------------------: | :----: | | iOS Simulator | x86_64 | Intel-based simulators | ✅ | | iOS Simulator | arm64 | Apple Silicon simulators | ✅ | | iOS Device | arm64 | All modern iOS devices | ✅ | | Android | arm64-v8a | Modern Android devices (64-bit ARM) | ✅ | | Android | armeabi-v7a | Older Android devices (32-bit ARM) | ✅ | | Android | x86 | Android emulators (32-bit Intel/AMD) | ✅ | | Android | x86_64 | Android emulators (64-bit Intel/AMD) | ✅ |
Contributing
Contributions are welcome! Please read our Contributing Guide for details on:
- Code of conduct
- Development workflow
- Pull request process
- Coding standards
License
MIT License - see the LICENSE file for details
Acknowledgments
- Shiki - The beautiful yet powerful syntax highlighter we bring to React Native
- Oniguruma - The blazing-fast regex engine powering our native implementation
- React Native - Making native module development possible with JSI and TurboModules
Support
For questions, bug reports, or feature requests:
