react-native-sized-webview
v1.3.0
Published
React Native WebView that auto-sizes to match its HTML content—whether you load local HTML or full external websites—without manual measurements, timers, or layout flicker.
Downloads
694
Maintainers
Readme
react-native-sized-webview 📏
React Native WebView that auto-sizes to match its HTML content—whether you load local HTML or full external websites—without manual measurements, timers, or layout flicker.
[!IMPORTANT] ⚡️
SizedWebViewkeeps the parent scroll view in charge by disabling the inner WebView scroll and syncing height changes via a lightweight bridge.
[!TIP] 💡 Works out-of-the-box with dynamic CMS pages, FAQs, marketing landers, local HTML snippets, or full external sites.
🚨 Upgrading to 1.1.0
Three defaults changed in the 1.1.x line. Each is a one-line migration:
- Named import only. The default export was removed to keep tree-shaking predictable across bundlers.
- import SizedWebView from 'react-native-sized-webview'; + import { SizedWebView } from 'react-native-sized-webview'; originWhitelistnow defaults to['http://*', 'https://*']. Standard HTTP(S) navigation keeps working; non-web schemes (file:,javascript:,data:,intent:) are blocked by default. Tighten it for production if you only load a specific origin:<SizedWebView + originWhitelist={['https://your-trusted-domain.com']} source={{ uri: 'https://your-trusted-domain.com/page' }} />javaScriptEnabledis now respected. Passingfalsedisables auto-sizing; the container falls back tominHeight(orcontainerStyle.height). This unblocks rendering static HTML on iOS 26 (#3).
✨ Highlights
- 📐 Wrapper-based measurement keeps the WebView content in a dedicated container, so height always reflects the real DOM footprint.
- 🚀 Modern pipeline powered by
ResizeObserver,MutationObserver,visualViewport, and font-load events with graceful fallbacks. - 🖼 Media aware: images, iframes, and videos schedule immediate + next-frame re-measures as soon as they finish loading.
- 🧼 Auto-prunes trailing
<br>/empty<p>tags that CMS editors often append, eliminating phantom spacing. - 🛡️ Sanity guard clamps runaway heights and retries with the last good value, so flaky pages never lock your layout.
- 🧵 Keeps the WebView scroll-disabled so outer
ScrollViews and gesture handlers stay silky smooth. - 🎨 Transparent background by default; style the container however you like.
- ⚙️ Friendly API with
minHeight,containerStyle,loadingContainerStyle, andonHeightChangecallbacks. - 🌲 ESM-first build, fully typed,
sideEffects: falsefor optimal tree shaking. - 📱 Verified on iOS, Android, and Expo Go out of the box.
📦 Installation
yarn add react-native-sized-webview react-native-webview
# or
npm install react-native-sized-webview react-native-webviewNo native steps are needed beyond the upstream react-native-webview dependency.
🚀 Quick Start
import { SizedWebView } from 'react-native-sized-webview';
const Article = () => (
<SizedWebView
minHeight={180}
source={{
html: `
<html>
<body>
<h1>Privacy policy</h1>
<p>Generated by your CMS and sized automatically ✨</p>
</body>
</html>
`,
}}
containerStyle={{ borderRadius: 12, overflow: 'hidden' }}
onHeightChange={(height) => console.log('content height', height)}
/>
);🧪 Example App
yarn
yarn example ios # or yarn example androidThe example showcases four scenarios the auto-sizing pipeline must handle correctly:
- Local HTML demo — toggle a switch to mutate the document and watch the WebView re-size live.
- Remote site picker — swap between Marvel, NFL, Google, Wikipedia, and The Verge to verify CMS-driven layouts resolve cleanly.
- Custom Google Font — a local HTML page that imports the
Lobsterfont over the network. The WebView hangs briefly while the font downloads, then the bridge re-measures viadocument.fonts.loadingdoneand snaps to the final height with no clipping. - Long-form article — a CMS-style payload with lazy images and trailing margins that exercises the multi-source measurement path (
scrollHeight+getBoundingClientRect().bottom + computedMarginBottom).
The demo is also wired up with babel-plugin-react-compiler so you can see how the library composes inside a React Compiler–enabled app.
[!NOTE] 🧪 The demo is built with Expo; swap any
urito test your own pages instantly.
⚙️ API
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| minHeight | number | 0 | Minimum height (dp) applied to the container. When 0, the container is unsized until the first measurement arrives (avoids layout flicker and the iOS 26 WKWebView 1px feedback loop). |
| containerStyle | StyleProp<ViewStyle> | — | Styles applied to the wrapping View. Use it for padding, borders, or shadows. Do not set height — it is managed by the hook. |
| loadingContainerStyle | StyleProp<ViewStyle> | — | Styles applied to the wrapping View only while height is still being measured (i.e. while the bridge has not yet reported a value). Use { flex: 1 } inside a ScrollView with contentContainerStyle={{ flexGrow: 1 }} so the native activity indicator is never clipped. Dropped automatically once the first measurement commits. |
| onHeightChange | (height: number) => void | — | Callback fired whenever a new height is committed. Great for analytics or debugging. Never fires for invalid or out-of-range values. |
| originWhitelist | string[] | ['http://*', 'https://*'] | Origins the WebView is allowed to navigate to. Blocks non-web schemes (file:, javascript:, data:, intent:) by default. Tighten it to a specific origin list for stricter environments. |
| javaScriptEnabled | boolean | true | When false, the auto-height bridge is not injected and the container falls back to minHeight. Use for static HTML that doesn't need JS. |
| ...WebViewProps | — | — | All remaining props are forwarded to the underlying react-native-webview. User-supplied values always win over the defaults above. |
[!NOTE] 🧩
scrollEnableddefaults tofalseso sizing remains deterministic. Only enable it if the WebView should manage its own scroll.
Loading state inside a ScrollView
When minHeight is 0 (the default), the container has no height until the bridge reports the first measurement. Inside a ScrollView this means the native loading spinner is rendered in a 0 dp frame and gets clipped.
Fix it with loadingContainerStyle:
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
<SizedWebView
source={{ uri: 'https://example.com/article' }}
loadingContainerStyle={{ flex: 1 }}
containerStyle={{ borderRadius: 12, overflow: 'hidden' }}
renderLoading={() => <ActivityIndicator size="large" style={{ flex: 1 }} />}
/>
</ScrollView>- During loading: container is
[{ flex: 1 }, containerStyle]— fills the scroll area, spinner is fully visible. - After measurement: container becomes
[{ height: N }, containerStyle]— shrinks to content height,loadingContainerStyleis dropped.
🛡️ Security
- Namespaced message protocol. The injected bridge posts values prefixed with
__RN_SIZED_WV__:and the hook rejects everything else, so your ownonMessagetraffic cannot accidentally (or maliciously) mutate the container height. - Safe-by-default origin list.
originWhitelistdefaults to['http://*', 'https://*']— HTTP(S) navigation works, but non-web schemes (file:,javascript:,data:,intent:) are blocked. Tighten to a specific origin for production apps that only load trusted content. - Respected JS toggle.
javaScriptEnabled={false}is honored; the bridge is not injected when you disable scripts. - Clamped heights. A shared
MAX_COMMITTED_HEIGHT(120 000 dp) caps both sides of the bridge to defend against runaway values from broken markup or third-party scripts. - No native code. This package ships only JavaScript/TypeScript — there is no Objective-C, Swift, Java, or Kotlin to audit.
- Warning. Never interpolate untrusted strings into
injectedJavaScriptorinjectedJavaScriptBeforeContentLoaded. Anything passed there runs inside the WebView page context and can reach React Native throughwindow.ReactNativeWebView.
🧩 Edge Cases Covered
- Trailing
<br>and empty<p>tags are stripped automatically so CMS exports don’t leave phantom padding. - Images, iframes, and videos reschedule measurements the moment they finish loading—perfect for hero images at the end of an article.
- Wrapper rebuild + fallback timers keep measurements stable even if the remote page rewrites the entire DOM after load.
- Measurements above safe bounds are retried and then clamped to the last known good height, protecting against broken markup or third-party scripts.
🧠 How It Works
The injected bridge is idempotent and may run at both injectedJavaScriptBeforeContentLoaded and injectedJavaScript (the second injection is a no-op when the first already published the global handle, but covers iOS inline source.html cases where the early hook is skipped). It turns the WebView into a self-measuring component:
Measures the page in place. The bridge does not re-parent
<body>children into a wrapper and does not inject inline styles; it reads layout directly from the document's existing flow so framework- or CMS-generated DOM stays untouched (preserving margin collapse, author CSS, and any structure the page expects).Multi-source measurement (the production-grade fix). Each measurement is the
Math.maxof authoritative layout sources:document.body.scrollHeight/document.body.offsetHeight— primary document metrics for normal block flow.document.documentElement.scrollHeight/document.documentElement.offsetHeight— backstop when frameworks stylehtmldirectly or when the root box exceedsbody.- The last non-inert in-flow child's
getBoundingClientRect().bottom + computedMarginBottom— catches margin-collapse, late image reflow, and end-of-document cases where scroll metrics momentarily under-report on iOS WKWebView.
Inert siblings (
SCRIPT,STYLE,META,LINK,TITLE,HEAD,NOSCRIPT) and out-of-flow positions (fixed/sticky/absolute) are skipped during the last-in-flow-child walk so they never short-circuit the probe.Bootstrap-grace adaptive fallback. A timer re-arms itself only while either condition holds:
pendingLoads > 0(an image / iframe / video is still loading), orDate.now() - bootstrapAt < BOOTSTRAP_GRACE_MS(5 s grace window from script start, refreshed onmarkLoading, fontloadingdone, and externalstate.refreshcalls).
Once both expire only signal-driven re-measures (mutation, resize, font, viewport, message) trigger work — the steady-state CPU cost is zero.
Signal-driven observers.
MutationObserver,ResizeObserver,visualViewport, font-load events, and a namespacedpostMessagechannel each schedule a single rAF-batched measure.Rendered through
useAutoHeight. Heights are validated, clamped toMAX_COMMITTED_HEIGHT(120 000 dp), diff-thresholded, and committed at most once per animation frame.Public surface stays small. The package exports the bridge string, the hook, and helpers individually, so you can build bespoke wrappers (e.g. around a custom WebView component) without forking.
⚖️ Performance Snapshot
| Scenario | Plain react-native-webview | react-native-sized-webview |
| --- | --- | --- |
| Initial render layout shifts | Requires timers / manual height guesswork | Zero shifts; height resolved before paint |
| React state updates on content change | Manual postMessage plumbing | Automatic bridge with RAF + debounce guard |
| Scrolling in parent ScrollView | Nested scroll can fight gestures | Parent retains full momentum and gesture priority |
Benchmarks were captured on CMS articles up to 3k words in a 60 fps RN dev build. The bridge batches DOM mutations so even long documents resize without thrashing the JS thread.
🏎️ Built for speed
Every hot path is designed to run at its theoretical complexity floor — no allocations in steady state, no repeated DOM walks, and at most one forced layout per measurement frame.
| Hot path | Complexity | Notes |
| --- | --- | --- |
| Message parsing (useAutoHeight) | O(1) | Namespaced-prefix check, single Number() coerce, constant-bound clamp. |
| Height commit (rAF-batched) | O(1) amortized per frame | Sub-pixel diffs are dropped; at most one React render per animation frame. |
| DOM mutation callback | O(added nodes) | Scans only each mutation's addedNodes, not the whole tree. Media elements are deduped via a WeakSet. |
| measureHeight | O(k), single forced reflow | Math.max of scrollHeight/offsetHeight (constant) + a getBoundingClientRect() on the last non-inert child (k = number of trailing inert siblings, typically 0–2). |
| Trailing-node prune DFS | Runs only when the DOM is dirty | A mutation-driven dirty flag skips the recursive walk on resize / font / viewport ticks when nothing structural changed. |
| Late web-font reflow | Bootstrap-grace + adaptive | Font loadingdone refreshes the bootstrap window; the fallback timer keeps re-arming with 1.5× backoff until layout settles, then disarms automatically. |
The net effect: resize storms, font loads, and viewport changes cost a single layout flush per frame — nothing more. Paired with sideEffects: false and named-only exports, the library stays fast and small in the final bundle. The library is also compiled with babel-plugin-react-compiler, so memoization is automatic and free of stale closures.
📦 Bundle & tree-shaking
- Ships as ESM-first (
lib/module/**) with"sideEffects": false. - Named exports only — no default export — so every bundler can drop what you don't use.
- Importing only
useAutoHeightorcomposeInjectedScriptdoes not pull the injected-bridge string into your bundle.
✅ Testing
yarn testJest runs with full coverage collection and enforces 100% statements, branches, functions, and lines across the TypeScript source.
🛠️ Local Development
yarn
yarn lint
yarn typecheck
yarn test --watch=false
yarn example ios # or yarn example androidThis project uses react-native-builder-bob for packaging and release-it for publishing.
🤝 Contributing
[!CAUTION] 🔬 Before submitting PRs that touch the bridge script, please test the example app on both iOS and Android to catch edge cases with interactive embeds.
📄 License
MIT © Mateus Andrade
