nitro-webview
v0.1.0
Published
nitro-webview
Readme
nitro-webview
A React Native WebView built on Nitro Modules — pure Swift / Kotlin native sides, JSI-direct prop and event dispatch, no bridge round-trips.
Introduction
nitro-webview is a drop-in WebView component for React Native that replaces the legacy bridge with Nitro Modules's JSI-direct dispatch. It targets two audiences:
- Experienced RN + Nitro developers who want a WebView that participates in the Nitro view contract —
getHostComponent, hybrid refs,callback(...)event handlers,Promise<T>method results — without paying for JSON serialization or thread-hops on every prop update or event. - Teams evaluating WebView libraries ("comparison shoppers") who already use
react-native-webviewand want to know what they keep, what changes, and what improves before they switch.
What you keep coming from react-native-webview
- Same conceptual props (
source,userAgent,injectedJavaScript,onLoadStart/onLoadEnd,onMessage,onError,onShouldStartLoadWithRequest,onFileDownload). - Same
window.ReactNativeWebView.postMessage(...)page-side contract. - Same
originWhitelist-style default (['http://*', 'https://*']) exposed asDEFAULT_ORIGIN_WHITELIST. - Same
WebViewNavigationTypestring union ('click' | 'formsubmit' | 'backforward' | 'reload' | 'formresubmit' | 'other') so existing call-sites compile unchanged.
What changes
- Event props must be wrapped in
callback(...)fromreact-native-nitro-modulesso Nitro can dispatch them on the right thread. onShouldStartLoadWithRequestreturnsPromise<boolean>directly — nolockIdentifierround-trip.asynccallbacks are awaited transparently.- Imperative methods (
goBack,evaluateJavaScript,getCookies,setCookie,clearCookies, …) live on the hybrid ref captured via thehybridRefprop, not on a Reactref. - Native packages:
io.github.l2hyunwoo.nitrowebview(Android) /NitroWebViewSwift module (iOS). MIT-licensed, npm-published asnitro-webview(unscoped).
Why Nitro
Nitro Modules pipes props, methods, and event callbacks through JSI so a load event or a cookie read does not round-trip through NativeEventEmitter or the bridge's serialization queue. For a WebView — which is event-heavy (navigation, messages, errors, downloads) — that is the main practical win.
Quick Start
1. Install
yarn add nitro-webview react-native-nitro-modules
cd ios && pod installreact-native-nitro-modules is a peer dependency — install it explicitly so your dependency graph stays deterministic.
2. Render a WebView
import { NitroWebView, callback } from 'nitro-webview'
export default function Screen() {
return (
<NitroWebView
style={{ flex: 1 }}
source={{ uri: 'https://example.com' }}
onLoadEnd={callback(() => console.log('loaded'))}
/>
)
}Every event prop must be wrapped in callback(...) so Nitro can dispatch it on the right thread. Passing a raw function will throw at render time.
3. Call imperative methods
import { useRef } from 'react'
import { NitroWebView, callback, type NitroWebViewType } from 'nitro-webview'
export default function Screen() {
const ref = useRef<NitroWebViewType | null>(null)
return (
<>
<NitroWebView
style={{ flex: 1 }}
source={{ uri: 'https://example.com' }}
hybridRef={callback((r) => {
ref.current = r
})}
/>
<Button title="reload" onPress={() => ref.current?.reload()} />
</>
)
}4. Configure platform setup
iOS and Android both need a small amount of host-app configuration for file upload and download to work — see Platform setup below.
API Reference
NitroWebView component
The exported React component. Backed by getHostComponent<NitroWebViewProps, NitroWebViewMethods>('NitroWebView', () => NitroWebViewConfig).
Props
| Prop | Type | Notes |
| --- | --- | --- |
| source | WebViewSource | { uri, headers? } or { html, baseUrl? }. Drives navigation. Required. |
| defaultHeaders | Record<string, string> | Global HTTP headers attached to every main-frame navigation request. Per-request source.headers win on key conflict. |
| userAgent | string | Overrides the platform default UA for every request (main-frame + sub-resource). undefined / empty restores the WebKit / Chromium default. |
| injectedJavaScript | string | Fire-and-forget script run on every page load. |
| onLoadStart | (event: WebViewLoadEvent) => void | Fired when the WebView begins loading content. |
| onLoadEnd | (event: WebViewLoadEvent) => void | Fired when the WebView finishes loading content. |
| onNavigationStateChange | (state: WebViewNavigationState) => void | URL / title / canGoBack / canGoForward / loading. |
| onMessage | (event: WebViewMessageEvent) => void | Fires when the page calls window.ReactNativeWebView.postMessage(...). |
| onError | (event: NitroWebViewErrorEvent) => void | Navigation failure (network, SSL). |
| onFileDownload | (event: FileDownloadEvent) => void | Native intercepts a download and surfaces { url, mimeType?, fileName?, contentLength?, userAgent? }. Storage is the JS layer's responsibility. |
| onShouldStartLoadWithRequest | (event: ShouldStartLoadRequest) => boolean \| Promise<boolean> | Allow/block each navigation before it starts. Returning false (or a Promise resolving to false) cancels silently. |
Methods (via hybridRef)
The hybrid ref captured by hybridRef={callback((r) => ref.current = r)} exposes:
| Method | Return | Notes |
| --- | --- | --- |
| goBack() | void | Navigate back in history. |
| goForward() | void | Navigate forward in history. |
| reload() | void | Reload the current page. |
| stopLoading() | void | Stop the current load. |
| evaluateJavaScript(code) | Promise<string> | Result is the serialized string evaluation. iOS uses String(describing:); Android uses the JSON-encoded ValueCallback<String> result. Undefined/nil surfaces as ''. |
| getCookies(url) | Promise<Cookie[]> | iOS returns the full attribute set. Android CookieManager only exposes name and value on read — other fields are left undefined. |
| setCookie(url, cookie) | Promise<void> | Cookie = { name, value, domain?, path?, expires?, secure?, httpOnly? }. expires is milliseconds since epoch (Date.now()-compatible). |
| clearCookies() | Promise<void> | Bulk clear via WKWebsiteDataStore (iOS) / CookieManager.removeAllCookies (Android). The promise resolves only after the platform reports completion. |
Types
WebViewSource
type WebViewSource = UriSource | HtmlSource
interface UriSource {
uri: string
headers?: Record<string, string>
}
interface HtmlSource {
html: string
baseUrl?: string
}UriSource.headers are per-request HTTP headers attached only to the main-frame navigation a source change triggers. Redirects, sub-frames, and sub-resource requests do not re-apply them.
ShouldStartLoadRequest
interface ShouldStartLoadRequest {
url: string
navigationType: WebViewNavigationType
mainDocumentURL?: string // iOS only
isTopFrame?: boolean // iOS only
hasTargetFrame?: boolean // iOS only — false for target=_blank
}
type WebViewNavigationType =
| 'click' | 'formsubmit' | 'backforward'
| 'reload' | 'formresubmit' | 'other'Android leaves the three optional fields undefined because WebViewClient.shouldOverrideUrlLoading does not expose them, and always reports navigationType: 'other'.
The JS callback may be async — the bridge transparently awaits any returned thenable before applying the decision.
WebViewNavigationState & WebViewLoadEvent
interface WebViewNavigationState {
url: string
title: string
loading: boolean
canGoBack: boolean
canGoForward: boolean
}
interface WebViewLoadEvent {
nativeEvent: WebViewNavigationState
}WebViewMessageEvent
interface WebViewMessageNativeEvent {
data: string // literal string from window.ReactNativeWebView.postMessage(...)
url: string
}
interface WebViewMessageEvent {
nativeEvent: WebViewMessageNativeEvent
}NitroWebViewErrorEvent
interface NitroWebViewErrorNativeEvent {
code: number // NSError.code (iOS) / WebResourceError.getErrorCode() (Android)
description: string // localizedDescription (iOS) / getDescription().toString() (Android)
url: string // empty string when neither delegate nor error provided one
domain: string // NSError.domain (iOS) / stable string mirror (Android)
}
interface NitroWebViewErrorEvent {
nativeEvent: NitroWebViewErrorNativeEvent
}
type WebViewErrorEvent = NitroWebViewErrorEvent // aliasCookie
interface Cookie {
name: string
value: string
domain?: string // platform-derived from url when omitted
path?: string // defaults to '/'
expires?: number // ms since Unix epoch; omit for a session cookie
secure?: boolean // restrict to HTTPS
httpOnly?: boolean // hide from document.cookie
}FileDownload & FileDownloadEvent
interface FileDownload {
url: string // always http/https — blob: URLs are out of scope
mimeType?: string
fileName?: string // iOS: URLResponse.suggestedFilename
// Android: DownloadUtils.guessFileName (Content-Disposition)
contentLength?: number // -1 or absent when the platform did not supply a length
userAgent?: string // typically absent on iOS
}
interface FileDownloadEvent {
nativeEvent: FileDownload
}Origin whitelist helpers
Pure-TS helpers for building allowlist-style policies on top of onShouldStartLoadWithRequest. They do not depend on React Native or Nitro at runtime, so they can be unit-tested in isolation.
import {
DEFAULT_ORIGIN_WHITELIST,
createOriginWhitelistGuard,
originMatches,
wrapWithOriginWhitelist,
} from 'nitro-webview'
import type {
OnShouldStartLoadWithRequest,
OriginWhitelistGuard,
} from 'nitro-webview'| Export | Signature | Notes |
| --- | --- | --- |
| DEFAULT_ORIGIN_WHITELIST | readonly ['http://*', 'https://*'] | Frozen. Mirrors react-native-webview's documented default. |
| originMatches(url, patterns) | (string, readonly string[]) => boolean | Returns true iff the origin (scheme://host[:port]) of url matches one of the glob patterns. * is the only wildcard. Case-insensitive on scheme + host. Empty pattern list returns false. Unparseable URL returns false. |
| createOriginWhitelistGuard(patterns?, inner?) | (readonly string[], OnShouldStartLoadWithRequest?) => OriginWhitelistGuard | Builds a guard that rejects non-matching origins immediately and delegates matching ones to inner (or allows them when inner is absent). |
| wrapWithOriginWhitelist(handler, patterns?) | (OnShouldStartLoadWithRequest, readonly string[]?) => OnShouldStartLoadWithRequest | Fast-path wrapper: when patterns === DEFAULT_ORIGIN_WHITELIST (by reference), the returned guard short-circuits true and handler is never invoked. Otherwise delegates straight to handler(event). |
import { wrapWithOriginWhitelist, DEFAULT_ORIGIN_WHITELIST } from 'nitro-webview'
const handler = wrapWithOriginWhitelist(
(event) => !event.url.startsWith('https://example.org/'),
DEFAULT_ORIGIN_WHITELIST,
)Source helpers
import {
isHtmlSource,
isUriSource,
normalizeHtmlSource,
sourceToCommand,
} from 'nitro-webview'| Export | Signature | Notes |
| --- | --- | --- |
| isUriSource(source) | (WebViewSource) => source is UriSource | Structural narrowing on a non-empty uri string. |
| isHtmlSource(source) | (WebViewSource) => source is HtmlSource | Structural narrowing on a string html field. |
| normalizeHtmlSource(source) | (WebViewSource) => LoadHtmlCommand \| null | Returns a loadHtml native command, or null when source is not an HtmlSource. |
| sourceToCommand(source) | (WebViewSource) => NativeViewCommand | Maps the source prop to the native view command (loadUrl or loadHtml). Throws TypeError on malformed input. |
Event dispatchers
Lower-level builders used by NitroWebView internally. Exported for advanced consumers building custom event pipelines (e.g. for tests or mocks).
| Export | Signature |
| --- | --- |
| createLoadStartDispatcher(onLoadStart?) | (OnLoadStart \| undefined) => LoadStartDispatcher |
| createLoadDispatcher(onLoad?) | (OnLoad \| undefined) => LoadDispatcher |
| createLoadEndDispatcher(onLoadEnd?) | (OnLoadEnd \| undefined) => LoadEndDispatcher |
Each dispatcher dedupes by navigationId so duplicate native fires never reach JS.
Bridge script
The injected window.ReactNativeWebView.postMessage(...) shim is built in pure TS so it can be unit-tested and shared across platforms.
import {
ANDROID_NATIVE_BRIDGE_NAME,
BRIDGE_NAME,
buildBridgeScript,
evaluateBridgeScript,
} from 'nitro-webview'| Export | Notes |
| --- | --- |
| BRIDGE_NAME | 'ReactNativeWebView'. Public identifier installed on window. |
| ANDROID_NATIVE_BRIDGE_NAME | 'ReactNativeWebViewNative'. Internal Android JavascriptInterface name. |
| buildBridgeScript(platform) | Returns the literal JavaScript source string for the injected bridge. Idempotent — never overwrites a page-defined postMessage. |
| evaluateBridgeScript(platform, sandbox) | Evaluates the script against an in-memory sandbox (used for tests). |
callback re-export
import { callback } from 'nitro-webview'Re-exported verbatim from react-native-nitro-modules. Every event prop (onLoadStart, onLoadEnd, onMessage, onError, onShouldStartLoadWithRequest, onFileDownload, hybridRef) must pass through this wrapper.
Platform setup
iOS file upload setup
The system file picker on iOS reads from the camera, the photo library, and (for video capture) the microphone. iOS crashes the app the first time the picker accesses one of these subsystems without an explanatory string. Add all three usage descriptions to your app's Info.plist even if your web content only triggers one of them — iOS may surface the unified picker:
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to let you upload photos and videos from web pages.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs photo library access to let you upload images from web pages.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app uses the microphone to record audio when you upload a video from a web page.</string>The strings are shown verbatim in the iOS permission prompt — rewrite them in your app's voice and supported locales.
Android file upload setup
The library ships its own FileProvider declaration with authority ${applicationId}.nitrowebview.fileprovider. The consuming app must still declare the media permissions in its AndroidManifest.xml for the file chooser to surface photos / videos / camera capture:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />The library also pulls org.mozilla.components:support-utils for its Content-Disposition–aware DownloadUtils.guessFileName — the consuming app must expose Mozilla's Maven repository in its android/build.gradle:
allprojects {
repositories {
maven { url "https://maven.mozilla.org/maven2" }
}
}Known platform limitations
These are inherent to the underlying WebKit / Chromium contracts, not bugs in this library:
onShouldStartLoadWithRequeston Android fires only for user-initiated navigations (<a href>taps, form submits, history actions). Programmatic loads —view.loadUrl(...)triggered by changing thesourceprop from JS — bypassWebViewClient.shouldOverrideUrlLoadingand do not invoke the hook. iOSWKNavigationDelegate.decidePolicyForfires for both programmatic and user-initiated navigations.defaultHeadersandUriSource.headersonly apply to the main-frame navigation asourceupdate triggers. Redirects, sub-frames, and sub-resource requests do not re-apply them.onFileDownloadnever auto-saves. On both platforms the library surfaces the URL + metadata and leaves storage to the JS layer (use@dr.pogodin/react-native-fs,react-native-blob-util, etc.). Blob URLs are out of scope.- Android
getCookies(url)returns cookies with onlynameandvaluepopulated (the platformCookieManager.getCookie(url)API does not expose the rest). iOS preserves the full attribute set. - Android's
onShouldStartLoadWithRequestwaits up to 250 ms on asynchronized.waitfor the JS callback to resolve. If the JS handler does not settle inside that window the navigation defaults to allow (mirrors RNW'sSHOULD_OVERRIDE_URL_LOADING_TIMEOUT_MS). iOS has no such timeout.
Example app
The example/ directory contains a bare React Native demo that exercises every feature:
cd example
yarn install
cd ios && pod install
cd ..
yarn start --reset-cache
# In another shell:
yarn ios # or: yarn androidThe demo panels exercise headers, cookies, file upload, file download, user-agent overrides, and navigation interception — each with the platform-specific quirks documented above.
Development
yarn typecheck
yarn lint
yarn test
yarn nitrogen # regenerate codegen after touching src/specs/*.nitro.ts
swift test # iOS native unit tests
cd example/android && ./gradlew :nitro-webview:testDebugUnitTestStyle guardrails
The repo ships two layers of automated style enforcement so CLAUDE.md's rules cannot drift silently:
A PostToolUse hook (
.claude/settings.json) lints every Edit/Write tool call againstscripts/claude-md-guardrails.sh. Claude Code sessions get the same gate by default.A git pre-commit hook at
scripts/git-hooks/pre-commitruns the same script against every staged file. Activate once per clone:git config core.hooksPath scripts/git-hooks
Both layers share the same rule set, so adding a rule means editing scripts/claude-md-guardrails.sh and nothing else.
License
MIT.
