featured image

One WebView for EPUB and PDF: Bridging React Native to a Shared In-Browser Renderer

Most reading apps run separate code paths for EPUB and PDF. FreedWise routes both formats through a single WebView backed by versioned vendor assets on the filesystem. The hard parts: a readiness handshake before sending highlight data, Android tap overlays that break when the viewport zooms, and enforcing an offline-only guarantee at the Content Security Policy level.

Published

Tue Sep 15 2026

Technologies Used

React Native Expo TypeScript WebView
Advanced 31 minutes

The path of least resistance for a reading app that handles both EPUBs and PDFs is to build two separate readers. EPUBs need epub.js and CFI-based positioning; PDFs need PDF.js and page-plus-coordinate-based positioning. The format-specific logic diverges early enough that keeping them unified means fighting the grain of both libraries continuously.

FreedWise unifies them anyway. Both formats render in the same WebView component, talk over the same postMessage protocol, and use the same highlight data structures. The tradeoff is real — this required 17 iterations of vendor asset management to stabilize — but the alternative was two readers, two highlight bridges, and two surfaces to maintain every time the rendering behavior needed to change.

What You Need Coming In

  • React Native experience, particularly with WebView
  • Comfortable with TypeScript and async patterns
  • Familiarity with postMessage in a browser context
  • No epub.js or PDF.js knowledge needed; the tutorial treats them as black boxes

Why the Assets Live on the Filesystem

epub.js, PDF.js, and jszip are large libraries — together around 1.8 MB. Bundling them directly into the React Native binary adds that weight to every install, and Metro’s asset resolution doesn’t give you control over how they’re served to a WebView. The WebView needs to load them as scripts via file:// URIs, and require() in Metro can produce paths that don’t resolve correctly from inside a WebView’s file context, especially on Android.

The approach: on first launch, extract the libraries from the Metro bundle to the app’s documents directory, then serve them from file:// on every subsequent launch. VendorAssetManager handles this:

export const LIB_VERSION = '17';

const LIB_DIR = (docRoot: string) =>
  `${docRoot}/webview-libs-v${LIB_VERSION}`;

export async function ensureInstalled(docRoot: string): Promise<void> {
  const dir = LIB_DIR(docRoot);
  const markerPath = `${dir}/.installed`;

  // Already on this version — skip extraction
  if (await FileSystem.getInfoAsync(markerPath).then(i => i.exists)) return;

  await FileSystem.makeDirectoryAsync(dir, { intermediates: true });

  // Copy each vendored asset from Metro bundle to filesystem
  for (const [name, asset] of Object.entries(VENDOR_ASSETS)) {
    const src = Asset.fromModule(asset);
    await src.downloadAsync();
    await FileSystem.copyAsync({ from: src.localUri!, to: `${dir}/${name}` });
  }

  // Write the HTML reader pages to disk
  await writeText(`${dir}/reader-pdf.html`, buildPdfPage(dir));
  await writeText(`${dir}/reader-epub.html`, buildEpubPage(dir));

  // Marker file gates re-extraction on future launches
  await writeText(markerPath, LIB_VERSION);
}

LIB_VERSION is the version gate. When I bump it — because the reader runtime changed, the tap behavior was fixed, or a new Android quirk was worked around — the marker file check fails, the old directory gets replaced, and every device gets the new assets on next launch. Users don’t need an app store update for reader behavior changes; they just need to open the app.

The VENDOR_ASSETS map points to require() calls for each library file. Metro resolves these at build time and includes them in the bundle; Asset.fromModule() gets the local URI after Expo extracts the bundle’s assets.

The Message Protocol

Communication between React Native and the WebView is entirely through postMessage in both directions. The WebView can’t call native code directly; React Native can’t touch the DOM directly. Everything goes through a typed event bus.

Messages the React Native side sends into the WebView:

interface OutboundMessage {
  type:
    | 'init'             // Start loading the book
    | 'rehydrateHighlights' // Restore existing highlights
    | 'flip'             // Page turn: 'prev' | 'next'
    | 'goToPage'         // Jump to page number or percentage
    | 'goToHighlight';   // Scroll to a specific highlight
  // ...type-specific fields
}

Messages the WebView sends back:

interface InboundMessage {
  type:
    | 'runtimeLoaded'   // vendor scripts loaded, reader JS ready
    | 'ready'           // book parsed and rendered, safe to navigate
    | 'rendered'        // pages are in the DOM, safe to jump to highlights
    | 'selection'       // user selected text: { text, position, boundingRect }
    | 'highlightTap'    // user tapped an existing highlight: { highlightId }
    | 'progress'        // page changed: { pdf: { page } } or { epub: { cfi, percentage } }
    | 'error';          // something failed in the reader
}

The React Native side translates these into state updates and callbacks:

const handleMessage = useCallback((event: WebViewMessageEvent) => {
  const msg: WVMessage = JSON.parse(event.nativeEvent.data);

  switch (msg.type) {
    case 'runtimeLoaded':
      postToWeb({ type: 'init', kind: bookKind, bookUri, ...readerConfig });
      break;
    case 'ready':
      setReady(true);
      break;
    case 'rendered':
      setRendered(true);
      pendingHighlightNav.current && navigateToHighlight(pendingHighlightNav.current);
      break;
    case 'selection':
      onSelection?.({ text: msg.text!, position: msg.position!, rect: msg.boundingRect! });
      break;
    case 'highlightTap':
      onHighlightTap?.(msg.highlightId!);
      break;
    case 'progress':
      onProgress?.(msg);
      break;
  }
}, [bookKind, bookUri, readerConfig, onSelection, onHighlightTap, onProgress]);

The init message carries all the configuration the reader needs: which format (pdf or epub), the file:// URI of the book, whether dark mode is active, font scale, and for EPUBs, the flow mode (paginated vs. scroll). The reader parses the book, signals ready, then signals rendered once content is in the DOM.

The Highlight Rehydration Race

When you reopen a book, the reader needs to restore your previous highlights — re-rendering them as colored overlays over the text. The naive implementation sends the highlights immediately after the WebView mounts.

This doesn’t work. The WebView goes through three sequential states before it can accept highlight data: the vendor scripts have to load (runtimeLoaded), the book has to parse (ready), and the pages have to render into the DOM (rendered). Sending rehydrateHighlights before rendered means the annotation layer doesn’t exist yet, and the message is silently dropped.

The fix is to gate rehydration on the rendered event:

// Rehydrate whenever highlights change *and* the reader is rendered
useEffect(() => {
  if (!rendered) return;
  postToWeb({
    type: 'rehydrateHighlights',
    highlights: highlights.map(h => ({
      highlightId: h.id,
      position: h.position,
      color: h.color,
    })),
  });
}, [rendered, highlights, postToWeb]);

The effect reruns whenever highlights changes — so if you add a new highlight while the book is open, it’s immediately reflected in the WebView without an explicit trigger. And because rendered is checked first, this effect is safe to call at any point in the lifecycle.

The same race applies to goToHighlight — jumping to a specific highlight’s position when the user taps on one in the highlight list. That navigation also has to wait for rendered, so it queues in a ref and fires from the rendered handler.

When the WebView Viewport Moves

This was the problem I spent the most time debugging. Early versions of FreedWise put the tap targets for page turning — the left and right edges of the screen — as fixed-position <div> elements inside the WebView HTML. That worked fine on iOS. On Android, it failed in a specific and confusing way.

On Android, when a user pinch-zooms content inside a WebView, the WebView’s pan gesture handler moves the entire rendered viewport. The <div> stays fixed relative to the viewport (as CSS intends), but the viewport itself has shifted. A tap on the “fixed” div at the right edge of the screen registers at the wrong document coordinates after a pan — the tap zone is visually in the right place but the hit detection is wrong.

The fix: remove the in-WebView tap overlays entirely and replace them with native Pressable components in React Native, layered over the WebView using absolute positioning.

<View style={{ flex: 1 }}>
  <WebView ref={webViewRef} style={StyleSheet.absoluteFill} {...webViewProps} />

  {/* Native tap zones — immune to WebView viewport transforms */}
  <Pressable
    style={[styles.tapZone, styles.tapLeft]}
    onPress={() => postToWeb({ type: 'flip', direction: 'prev' })}
  />
  <Pressable
    style={[styles.tapZone, styles.tapRight]}
    onPress={() => postToWeb({ type: 'flip', direction: 'next' })}
  />
</View>

React Native’s Pressable lives in screen space — it’s completely unaffected by what the WebView’s viewport is doing. The flip message still goes into the WebView, which handles the actual page navigation. The native layer just catches the tap and translates it into a message.

This also has a nice side effect: the tap zones can be styled and sized from React Native without touching the reader HTML, which means adjusting the edge width or adding visual feedback doesn’t require bumping LIB_VERSION.

Content Security Policy as the Offline Enforcement Mechanism

FreedWise is offline-first by design — no network, no accounts, nothing leaving the device. That’s easy to state as a policy and easy to accidentally violate in the reader, since epub.js’s default configuration will happily try to load remote resources from EPUB manifests that include external stylesheets or fonts.

The reader HTML template enforces it at the browser level:

const CSP =
  "default-src 'none'; " +
  "script-src 'self' 'unsafe-eval' 'unsafe-inline'; " +   // PDF.js requires eval
  "style-src 'self' 'unsafe-inline'; " +
  "img-src 'self' data: blob:; " +
  "font-src 'self' data:; " +
  "connect-src 'self' blob: data:;";                       // No http(s) origins

connect-src allows blob: and data: but no http:// or https:// origins. Any network request — whether from epub.js following a manifest link, from a script tag someone embedded in a vendor library, or from an EPUB that included remote content — gets blocked at the browser level before it can leave the device.

'unsafe-eval' is unavoidable for PDF.js, which uses eval internally for its worker code when the Worker API isn’t available in the WebView context. This is a known limitation of the PDF.js approach in WebView environments.

The originWhitelist={['file://*']} prop on the WebView component restricts which URLs the WebView itself can navigate to — it’ll refuse to follow a link to an https:// URL entirely, complementing the CSP’s request-level enforcement.

Between the CSP and the originWhitelist, a book that tried to phone home — or a library with a subtle external dependency — can’t get through. The offline guarantee holds at the architecture level rather than relying on developers remembering to avoid network calls.

What Made Version 17 Necessary

The version counter is a useful audit trail. Each increment addressed a real failure mode that wasn’t obvious until it appeared in use.

Version 3 fixed Android MIME type rejections — file:// URIs don’t carry content-type headers, and Android WebView was refusing to execute scripts loaded without them. The fix was restructuring the HTML template to load all scripts synchronously from a local index.html with explicit <script> tags rather than dynamic injection.

Version 6 addressed EPUB CFI drift. When a user switched between paginated and scroll mode mid-session, the CFI positions stored from paginated mode didn’t translate correctly into scroll mode’s coordinate system. The fix stored the flow mode alongside the highlight and invalidated CFIs on mode switch rather than attempting to preserve them across incompatible layout models.

Version 10 introduced the native tap overlays described above. In-WebView fixed-position divs broke under Android’s pan-on-zoom behavior.

Version 14 closed a readiness race: highlight rehydration was occasionally skipping the first few highlights when the WebView signaled ready slightly before the annotation layer was fully initialized. Added the rendered event as a second gate, which fires only after the annotation layer is confirmed present.

Each version bump is a small decision: is this change significant enough to force reinstall of the vendor assets on every device? The answer is yes if the behavior of the reader changed in a way that would leave old installed assets inconsistent with the current app. Cosmetic changes to native UI that don’t touch the HTML or JavaScript don’t bump the version; changes to the message protocol, the reader runtime, or the HTML template do.

Respecting your privacy.

← View All Tutorials

Related Projects

    Ask me anything!