Skip to content

mCodex/react-native-sized-webview

react-native-sized-webview 📏

npm version npm downloads coverage

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

⚡️ SizedWebView keeps 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';
  • originWhitelist now 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' }}
      />
  • javaScriptEnabled is now respected. Passing false disables auto-sizing; the container falls back to minHeight (or containerStyle.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, and onHeightChange callbacks.
  • 🌲 ESM-first build, fully typed, sideEffects: false for 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-webview

No 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 android

The example showcases four scenarios the auto-sizing pipeline must handle correctly:

  1. Local HTML demo — toggle a switch to mutate the document and watch the WebView re-size live.
  2. Remote site picker — swap between Marvel, NFL, Google, Wikipedia, and The Verge to verify CMS-driven layouts resolve cleanly.
  3. Custom Google Font — a local HTML page that imports the Lobster font over the network. The WebView hangs briefly while the font downloads, then the bridge re-measures via document.fonts.loadingdone and snaps to the final height with no clipping.
  4. 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 uri to 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

🧩 scrollEnabled defaults to false so 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, loadingContainerStyle is dropped.

🛡️ Security

  • Namespaced message protocol. The injected bridge posts values prefixed with __RN_SIZED_WV__: and the hook rejects everything else, so your own onMessage traffic cannot accidentally (or maliciously) mutate the container height.
  • Safe-by-default origin list. originWhitelist defaults 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 injectedJavaScript or injectedJavaScriptBeforeContentLoaded. Anything passed there runs inside the WebView page context and can reach React Native through window.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.max of authoritative layout sources:

    1. document.body.scrollHeight / document.body.offsetHeight — primary document metrics for normal block flow.
    2. document.documentElement.scrollHeight / document.documentElement.offsetHeight — backstop when frameworks style html directly or when the root box exceeds body.
    3. 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), or
    • Date.now() - bootstrapAt < BOOTSTRAP_GRACE_MS (5 s grace window from script start, refreshed on markLoading, font loadingdone, and external state.refresh calls).

    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 namespaced postMessage channel each schedule a single rAF-batched measure.

  • Rendered through useAutoHeight. Heights are validated, clamped to MAX_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 useAutoHeight or composeInjectedScript does not pull the injected-bridge string into your bundle.

✅ Testing

yarn test

Jest 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 android

This 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

About

📏 React Native WebView that auto-sizes itself, trims CMS fluff, observes images/iframes/videos, and keeps your parent scroll buttery smooth—no timers, no flicker, just reliable height updates everywhere.

Topics

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Sponsor this project