From 13c2bdf1c4df0a8aafe0d17b18305c139dd0b896 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 5 May 2026 10:03:19 -0300 Subject: [PATCH 1/3] Add example app and harden auto-height bridge Add a full example app (modular demo sections, shared theme, UI components, and HTML/data samples) to showcase four sizing scenarios: local HTML, remote sites, Google Font loading, and long-form articles. Update example tooling to run babel-plugin-react-compiler and add it to devDependencies. Refine the library internals and docs: expand README with measurement details and performance notes; improve SizedWebView docs and injection strategy (inject bridge at both documentStart and after-load, composeInjectedScript now includes the bridge only when JS is enabled). Rewrite and harden AUTO_HEIGHT_BRIDGE: switch to a multi-source measurement (scroll/offset + last non-inert child bounding rect + computed margin), replace bounded fallback counter with a bootstrap-grace window, expose a tiny frozen global handle (refresh/destroy/version) to avoid tampering, remove earlier wrapper mutation approach, and improve cleanup/idempotency. Tests updated: include new payload-rejection test for prefixed non-decimal messages and adapt existing tests to expect AUTO_HEIGHT_BRIDGE to be composed into injected script. Overall: adds example UX, visual tokens, and demos while making the auto-height measurement more robust and secure. --- README.md | 42 +- example/babel.config.js | 8 +- example/package.json | 1 + example/src/App.tsx | 267 ++-------- example/src/components/PillButton.tsx | 50 ++ example/src/components/SectionHeader.tsx | 34 ++ example/src/data/articleSamples.ts | 155 ++++++ example/src/data/googleFontDemo.ts | 76 +++ example/src/data/remotePages.ts | 29 ++ example/src/sections/GoogleFontDemo.tsx | 44 ++ example/src/sections/IntroDemo.tsx | 73 +++ example/src/sections/LongArticleDemo.tsx | 44 ++ example/src/sections/RemoteSitePicker.tsx | 86 ++++ example/src/styles/theme.ts | 33 ++ src/__tests__/index.test.tsx | 2 +- src/__tests__/useAutoHeight.test.tsx | 29 ++ src/components/SizedWebView.tsx | 62 ++- src/constants/autoHeightBridge.ts | 599 +++++++++++----------- src/hooks/useAutoHeight.ts | 50 +- 19 files changed, 1120 insertions(+), 564 deletions(-) create mode 100644 example/src/components/PillButton.tsx create mode 100644 example/src/components/SectionHeader.tsx create mode 100644 example/src/data/articleSamples.ts create mode 100644 example/src/data/googleFontDemo.ts create mode 100644 example/src/data/remotePages.ts create mode 100644 example/src/sections/GoogleFontDemo.tsx create mode 100644 example/src/sections/IntroDemo.tsx create mode 100644 example/src/sections/LongArticleDemo.tsx create mode 100644 example/src/sections/RemoteSitePicker.tsx create mode 100644 example/src/styles/theme.ts diff --git a/README.md b/README.md index 2f1d241..cb7c784 100644 --- a/README.md +++ b/README.md @@ -84,15 +84,17 @@ yarn yarn example ios # or yarn example android ``` -The example showcases: +The example showcases four scenarios the auto-sizing pipeline must handle correctly: -- Auto-sizing dynamic HTML with toggled sections. -- Live external sites (Marvel, NFL, Google, Wikipedia, The Verge) embedded without layout thrash. -- Real-time height readouts so you can verify your own endpoints quickly. -- One code path that works the same on iOS, Android, and Expo Go. +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`](https://react.dev/learn/react-compiler) so you can see how the library composes inside a React Compiler–enabled app. > [!NOTE] -> 🧪 The demo is built with Expo; swap the `uri` to test your own pages instantly. +> 🧪 The demo is built with Expo; swap any `uri` to test your own pages instantly. ## ⚙️ API @@ -147,12 +149,23 @@ Fix it with `loadingContainerStyle`: ## 🧠 How It Works -- Injected bridge re-parents all body children into a dedicated wrapper, trims trailing blanks, and observes DOM mutations, layout changes, font loads, and viewport shifts. -- Media events (images / iframes / video) trigger immediate + next-frame samples so late assets still report accurate heights. -- Media elements stay observed via `ResizeObserver` + decode promises, catching intrinsic size changes without duplicate network requests. -- Height calculations are debounced via `requestAnimationFrame` and a short idle timer to prevent resize storms. -- Measurements arrive through `postMessage`, then `useAutoHeight` coalesces them into a single render per frame. -- Package exports the bridge, hook, and helpers individually, making it easy to build bespoke wrappers when needed. +The injected bridge runs once per page, before content loads, and turns the WebView into a self-measuring component: + +- **Re-parents body children into a dedicated wrapper.** The wrapper has no explicit height and no `overflow: hidden`, so its layout is never clamped by the native frame size. +- **Multi-source measurement (the production-grade fix).** Each measurement is the `Math.max` of four authoritative layout sources: + 1. `wrapper.scrollHeight` / `wrapper.offsetHeight` — primary, fastest, accurate for normal block flow. + 2. `document.body.scrollHeight` / `documentElement.scrollHeight` — backstop when frameworks style `body`/`html` directly. + 3. The last non-inert child's `getBoundingClientRect().bottom + computedMarginBottom` — catches margin-collapse, late image reflow, and trailing absolutely-positioned content where `scrollHeight` momentarily under-reports on iOS WKWebView. + + Inert siblings (`SCRIPT`, `STYLE`, `META`, `LINK`, `TITLE`, `HEAD`, `NOSCRIPT`) are skipped during the last-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 @@ -173,10 +186,11 @@ Every hot path is designed to run at its theoretical complexity floor — no all | 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` | **1 forced reflow / call** | Reads the wrapper element only — its box is authoritative because every `` child lives inside it. | +| `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 `getBoundingClientRect()` per frame — nothing more. Paired with `sideEffects: false` and named-only exports, the library stays fast *and* small in the final bundle. +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`](https://react.dev/learn/react-compiler), so memoization is automatic and free of stale closures. ### 📦 Bundle & tree-shaking diff --git a/example/babel.config.js b/example/babel.config.js index bc38da3..59833ac 100644 --- a/example/babel.config.js +++ b/example/babel.config.js @@ -7,10 +7,16 @@ const root = path.resolve(__dirname, '..'); module.exports = (api) => { api.cache(true); - return getConfig( + const config = getConfig( { presets: ['babel-preset-expo'], }, { root, pkg } ); + + // React Compiler must run before other transforms. React 19 ships its own + // runtime, so no `react-compiler-runtime` package is required. + config.plugins = ['babel-plugin-react-compiler', ...(config.plugins ?? [])]; + + return config; }; diff --git a/example/package.json b/example/package.json index 4a51906..68edc62 100644 --- a/example/package.json +++ b/example/package.json @@ -21,6 +21,7 @@ }, "private": true, "devDependencies": { + "babel-plugin-react-compiler": "^1.0.0", "react-native-builder-bob": "^0.41.0", "react-native-monorepo-config": "^0.3.3" } diff --git a/example/src/App.tsx b/example/src/App.tsx index 339232f..d7f3853 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,188 +1,48 @@ -import { useMemo, useState } from 'react'; import { - Pressable, ScrollView, StyleSheet, - Switch, Text, useColorScheme, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { SizedWebView } from 'react-native-sized-webview'; - -const MARKDOWN_SAMPLE = ` - - - - - - -

Auto-sized WebView

-

- This SizedWebView expands to match the height of its HTML content, meaning - your outer ScrollView stays in full control of the scrolling behaviour. -

-

- Try toggling the switch below to view an extended version of the article. The WebView will - recalculate its intrinsic height on the fly without flicker or layout jumps. -

- - -`; - -const EXTENDED_SECTION = ` -
-

When should you use it?

- -

- The component listens to mutations and resizes using requestAnimationFrame to avoid - blocking the main thread. -

-
-`; - -const REMOTE_PAGES = [ - { - id: 'marvel', - label: 'Marvel', - uri: 'https://www.marvel.com/', - }, - { - id: 'nfl', - label: 'NFL', - uri: 'https://www.nfl.com/', - }, - { - id: 'google', - label: 'Google', - uri: 'https://www.google.com/search?q=marvel+studios', - }, - { - id: 'wikipedia', - label: 'Wikipedia', - uri: 'https://en.wikipedia.org/wiki/Marvel_Cinematic_Universe', - }, - { - id: 'verge', - label: 'The Verge', - uri: 'https://www.theverge.com/tech', - }, -]; +import { GoogleFontDemo } from './sections/GoogleFontDemo'; +import { IntroDemo } from './sections/IntroDemo'; +import { LongArticleDemo } from './sections/LongArticleDemo'; +import { RemoteSitePicker } from './sections/RemoteSitePicker'; +import { colors, spacing } from './styles/theme'; + +/** + * Showcase app for `react-native-sized-webview`. Demonstrates four scenarios + * the auto-sizing pipeline must handle correctly: + * + * 1. Local HTML with live mutation ({@link IntroDemo}). + * 2. Remote websites with arbitrary CMS markup ({@link RemoteSitePicker}). + * 3. Local HTML loading a custom Google Font ({@link GoogleFontDemo}). + * 4. Long, image-heavy CMS article ({@link LongArticleDemo}). + */ export default function App() { - const colorScheme = useColorScheme(); - - const [showExtended, setShowExtended] = useState(false); - - const [selectedPageId, setSelectedPageId] = useState( - () => REMOTE_PAGES[0]?.id ?? 'marvel' - ); - - const [remoteHeight, setRemoteHeight] = useState(null); - - const htmlSource = useMemo(() => { - return { - html: showExtended - ? MARKDOWN_SAMPLE.replace('', `${EXTENDED_SECTION}`) - : MARKDOWN_SAMPLE, - }; - }, [showExtended]); - - const remoteSource = useMemo(() => { - const selected = REMOTE_PAGES.find((page) => page.id === selectedPageId); - return selected ? { uri: selected.uri } : undefined; - }, [selectedPageId]); + const isDark = useColorScheme() === 'dark'; return ( - + - react-native-sized-webview - - Resize-friendly WebView that plays nicely with your native layout. - - - - Show extended article - - - - - - - Tip: The wrapping ScrollView keeps momentum scrolling smooth because - the WebView stays height-locked to its content. - - - - External Websites - - Tap a provider to load the live site inside the auto-sized WebView. + + react-native-sized-webview + + Resize-friendly WebView that plays nicely with your native layout. - - {REMOTE_PAGES.map((page) => { - const isActive = page.id === selectedPageId; - return ( - { - setSelectedPageId(page.id); - setRemoteHeight(null); - }} - style={[styles.siteButton, isActive && styles.siteButtonActive]} - > - - {page.label} - - - ); - })} - - - {remoteSource ? ( - - ) : null} - - - {remoteHeight == null - ? 'Waiting for remote content to size…' - : `Rendered height: ${Math.round(remoteHeight).toLocaleString()} dp`} - + + + + ); @@ -191,87 +51,28 @@ export default function App() { const styles = StyleSheet.create({ safeArea: { flex: 1, - backgroundColor: '#f8fafc', + backgroundColor: colors.bg, }, safeAreaDark: { - backgroundColor: '#0f172a', + backgroundColor: colors.bgDark, }, scrollView: { flex: 1, }, contentContainer: { - padding: 24, - gap: 16, + padding: spacing.xl, + gap: spacing.xl, + }, + intro: { + gap: spacing.sm, }, title: { fontSize: 24, fontWeight: '600', - color: '#0f172a', + color: colors.textPrimary, }, subtitle: { fontSize: 16, - color: '#475569', - }, - switchRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 4, - }, - switchLabel: { - fontSize: 16, - color: '#1e293b', - }, - webviewContainer: { - borderRadius: 12, - overflow: 'hidden', - borderWidth: 1, - borderColor: '#cbd5f5', - backgroundColor: '#ffffff', - }, - footer: { - fontSize: 14, - color: '#64748b', - }, - sectionHeader: { - gap: 4, - }, - sectionTitle: { - fontSize: 20, - fontWeight: '600', - color: '#0f172a', - }, - sectionSubtitle: { - fontSize: 14, - color: '#475569', - }, - siteSelector: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - }, - siteButton: { - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 999, - borderWidth: 1, - borderColor: '#cbd5f5', - backgroundColor: '#ffffff', - }, - siteButtonActive: { - borderColor: '#2563eb', - backgroundColor: '#dbeafe', - }, - siteButtonText: { - fontSize: 14, - color: '#1e293b', - }, - siteButtonTextActive: { - color: '#1d4ed8', - fontWeight: '600', - }, - remoteHint: { - fontSize: 13, - color: '#64748b', + color: colors.textSecondary, }, }); diff --git a/example/src/components/PillButton.tsx b/example/src/components/PillButton.tsx new file mode 100644 index 0000000..e4829a0 --- /dev/null +++ b/example/src/components/PillButton.tsx @@ -0,0 +1,50 @@ +import { Pressable, StyleSheet, Text } from 'react-native'; + +import { colors, radius, spacing } from '../styles/theme'; + +export interface PillButtonProps { + readonly label: string; + readonly active?: boolean; + readonly onPress: () => void; +} + +/** + * Pill-shaped toggle button used by section pickers (e.g. the remote-site + * selector). Visual styling is pulled from the shared theme so every demo + * stays in sync. + */ +export const PillButton = ({ + label, + active = false, + onPress, +}: PillButtonProps) => ( + + {label} + +); + +const styles = StyleSheet.create({ + button: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.pill, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + buttonActive: { + borderColor: colors.borderActive, + backgroundColor: colors.bgActive, + }, + text: { + fontSize: 14, + color: colors.textBody, + }, + textActive: { + color: colors.textActive, + fontWeight: '600', + }, +}); diff --git a/example/src/components/SectionHeader.tsx b/example/src/components/SectionHeader.tsx new file mode 100644 index 0000000..a9f971a --- /dev/null +++ b/example/src/components/SectionHeader.tsx @@ -0,0 +1,34 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { colors, spacing } from '../styles/theme'; + +export interface SectionHeaderProps { + readonly title: string; + readonly subtitle?: string; +} + +/** + * Reusable two-line header used at the top of every demo section. Keeps the + * example app's visual rhythm consistent without duplicating styles. + */ +export const SectionHeader = ({ title, subtitle }: SectionHeaderProps) => ( + + {title} + {subtitle ? {subtitle} : null} + +); + +const styles = StyleSheet.create({ + container: { + gap: spacing.xs, + }, + title: { + fontSize: 20, + fontWeight: '600', + color: colors.textPrimary, + }, + subtitle: { + fontSize: 14, + color: colors.textSecondary, + }, +}); diff --git a/example/src/data/articleSamples.ts b/example/src/data/articleSamples.ts new file mode 100644 index 0000000..394037f --- /dev/null +++ b/example/src/data/articleSamples.ts @@ -0,0 +1,155 @@ +/** + * Static HTML samples used by the in-app demos. Kept as plain strings so the + * Metro bundler can inline them without any extra asset plumbing. + */ + +const BASE_STYLE = ` + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + padding: 16px; + margin: 0; + color: #0f172a; + } + h1 { font-size: 28px; margin-bottom: 12px; } + h2 { font-size: 22px; margin: 24px 0 8px; } + p { font-size: 16px; line-height: 1.52; } + ul { padding-left: 22px; } + code { + background: rgba(0,0,0,0.05); + padding: 2px 4px; + border-radius: 4px; + } +`; + +export const MARKDOWN_SAMPLE = ` + + + + + + +

Auto-sized WebView

+

+ This SizedWebView expands to match the height of its HTML content, meaning + your outer ScrollView stays in full control of the scrolling behaviour. +

+

+ Try toggling the switch below to view an extended version of the article. The WebView will + recalculate its intrinsic height on the fly without flicker or layout jumps. +

+ + +`; + +export const EXTENDED_SECTION = ` +
+

When should you use it?

+
    +
  • Rendering CMS-driven content without fixed dimensions;
  • +
  • Embedding FAQ or policy pages in your native app;
  • +
  • Displaying components generated on the fly, such as charts.
  • +
+

+ The component listens to mutations and resizes using requestAnimationFrame to avoid + blocking the main thread. +

+
+`; + +/** + * Composes the markdown sample optionally including the extended section. + * Pure function — safe to call inside a `useMemo` selector. + */ +export const buildMarkdownSource = (extended: boolean): string => + extended + ? MARKDOWN_SAMPLE.replace('', `${EXTENDED_SECTION}`) + : MARKDOWN_SAMPLE; + +/** + * CMS-style long article that mirrors the production payload that historically + * tripped the v1.1 measurement algorithm (deeply-nested figures, late image + * reflow, generous trailing whitespace). Rendering this through `SizedWebView` + * is the canonical regression test for the multi-source measurement strategy. + */ +export const LONG_ARTICLE = ` + + + + + + +

The Long Read

+

+ This article reproduces the layout shape of a typical headless-CMS payload: + mixed prose, lazy-loaded imagery and trailing footnotes. The bridge measures + the maximum of scrollHeight, offsetHeight and the + bottom of the last non-inert child via getBoundingClientRect(), so the + container resolves to the correct height even when the page settles late. +

+
+ Mountain landscape +
Lazy-loaded hero image — extends the page height after first paint.
+
+

Body

+

+ Editorial content frequently triggers post-load reflows: web fonts arrive late, + embedded media decodes asynchronously, and ad slots inject content that pushes + the document down by hundreds of pixels. Single-source measurement (e.g. + document.body.scrollHeight alone) misses these tail-end deltas. +

+

+ With multi-source probing the bridge always resolves to the largest plausible + height, while the bootstrap-grace fallback timer keeps re-arming for a few + seconds after every signal so slow networks never starve the measurement. +

+
+ Forest +
Second figure — exercises the trailing-margin code path.
+
+
+ The wrapper div has no explicit height and no overflow: hidden, so the + injected script never clamps the document to the native viewport. +
+

Footnotes

+
    +
  • iOS WKWebView reports scrollHeight in CSS pixels.
  • +
  • Android WebView batches resize callbacks every 100 ms by default.
  • +
  • Both honour the CSSOM View spec for getBoundingClientRect.
  • +
+

+ End of article — the trailing margin below this paragraph is included in the + committed height thanks to the computedMarginBottom probe. +

+ + +`; diff --git a/example/src/data/googleFontDemo.ts b/example/src/data/googleFontDemo.ts new file mode 100644 index 0000000..04868bf --- /dev/null +++ b/example/src/data/googleFontDemo.ts @@ -0,0 +1,76 @@ +/** + * Local HTML payload that pulls a Google Font (`Lobster`) over the network + * before the body becomes typographically stable. The WebView will hang for a + * few hundred milliseconds while the `@font-face` resource downloads — once + * the `FontFaceSet`'s `loadingdone` event fires, the bridge re-measures the + * document and the container resolves to its final height. + * + * This demo proves the bootstrap-grace fallback strategy: even though the + * initial paint reports a smaller `scrollHeight`, the measurement is rerun + * after font swap so the committed height stays accurate. + */ +export const GOOGLE_FONT_HTML = ` + + + + + + + + +

Hello, Lobster

+

+ This page imports the Lobster Google Font over the network. + The WebView hangs briefly while the font downloads, then the bridge + re-measures and the container snaps to the final height — no clipping. +

+
+

Why this works

+

+ The injected bridge listens to document.fonts' + loadingdone event and refreshes the bootstrap window so + the adaptive fallback keeps probing until the layout settles. +

+
+ + +`; diff --git a/example/src/data/remotePages.ts b/example/src/data/remotePages.ts new file mode 100644 index 0000000..617980b --- /dev/null +++ b/example/src/data/remotePages.ts @@ -0,0 +1,29 @@ +/** + * Catalogue of public websites used by the {@link RemoteSitePicker} demo to + * exercise the auto-sizing pipeline against real-world CMS pages. + */ + +export interface RemotePage { + readonly id: string; + readonly label: string; + readonly uri: string; +} + +export const REMOTE_PAGES: readonly RemotePage[] = [ + { id: 'marvel', label: 'Marvel', uri: 'https://www.marvel.com/' }, + { id: 'nfl', label: 'NFL', uri: 'https://www.nfl.com/' }, + { + id: 'google', + label: 'Google', + uri: 'https://www.google.com/search?q=marvel+studios', + }, + { + id: 'wikipedia', + label: 'Wikipedia', + uri: 'https://en.wikipedia.org/wiki/Marvel_Cinematic_Universe', + }, + { id: 'verge', label: 'The Verge', uri: 'https://www.theverge.com/tech' }, +] as const; + +export const DEFAULT_REMOTE_PAGE_ID: RemotePage['id'] = + REMOTE_PAGES[0]?.id ?? 'marvel'; diff --git a/example/src/sections/GoogleFontDemo.tsx b/example/src/sections/GoogleFontDemo.tsx new file mode 100644 index 0000000..e0f13e3 --- /dev/null +++ b/example/src/sections/GoogleFontDemo.tsx @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { SizedWebView } from 'react-native-sized-webview'; + +import { SectionHeader } from '../components/SectionHeader'; +import { GOOGLE_FONT_HTML } from '../data/googleFontDemo'; +import { colors, radius, spacing } from '../styles/theme'; + +/** + * Local HTML payload that loads the `Lobster` Google Font over the network. + * The WebView hangs briefly while the font downloads — once it lands, the + * bridge listens to `document.fonts.loadingdone`, refreshes the bootstrap + * window and the container snaps to the final height with no clipping. + */ +export const GoogleFontDemo = () => { + const source = useMemo(() => ({ html: GOOGLE_FONT_HTML }), []); + + return ( + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: spacing.lg, + }, + webview: { + borderRadius: radius.card, + overflow: 'hidden', + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, +}); diff --git a/example/src/sections/IntroDemo.tsx b/example/src/sections/IntroDemo.tsx new file mode 100644 index 0000000..ea9d02a --- /dev/null +++ b/example/src/sections/IntroDemo.tsx @@ -0,0 +1,73 @@ +import { useMemo, useState } from 'react'; +import { StyleSheet, Switch, Text, View } from 'react-native'; +import { SizedWebView } from 'react-native-sized-webview'; +import { SectionHeader } from '../components/SectionHeader'; +import { buildMarkdownSource } from '../data/articleSamples'; +import { colors, spacing } from '../styles/theme'; + +/** + * Local-HTML demo with a toggle that mutates the document so the WebView + * height re-resolves on the fly. Demonstrates the basic `SizedWebView` + * usage pattern. + */ +export const IntroDemo = () => { + const [showExtended, setShowExtended] = useState(false); + + const source = useMemo( + () => ({ html: buildMarkdownSource(showExtended) }), + [showExtended] + ); + + return ( + + + + + Show extended article + + + + + + + Tip: the wrapping ScrollView keeps momentum scrolling smooth because the + WebView stays height-locked to its content. + + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: spacing.lg, + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.xs, + }, + switchLabel: { + fontSize: 16, + color: colors.textBody, + }, + webview: { + borderRadius: 12, + overflow: 'hidden', + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + footer: { + fontSize: 14, + color: colors.textMuted, + }, +}); diff --git a/example/src/sections/LongArticleDemo.tsx b/example/src/sections/LongArticleDemo.tsx new file mode 100644 index 0000000..a02f27a --- /dev/null +++ b/example/src/sections/LongArticleDemo.tsx @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { SizedWebView } from 'react-native-sized-webview'; + +import { SectionHeader } from '../components/SectionHeader'; +import { LONG_ARTICLE } from '../data/articleSamples'; +import { colors, radius, spacing } from '../styles/theme'; + +/** + * CMS-style long article that mirrors the production payload used to validate + * the multi-source measurement algorithm. Lazy-loaded images and trailing + * margins force the bridge to combine `scrollHeight` with the + * `getBoundingClientRect()` probe to land on the correct final height. + */ +export const LongArticleDemo = () => { + const source = useMemo(() => ({ html: LONG_ARTICLE }), []); + + return ( + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: spacing.lg, + }, + webview: { + borderRadius: radius.card, + overflow: 'hidden', + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, +}); diff --git a/example/src/sections/RemoteSitePicker.tsx b/example/src/sections/RemoteSitePicker.tsx new file mode 100644 index 0000000..33265d3 --- /dev/null +++ b/example/src/sections/RemoteSitePicker.tsx @@ -0,0 +1,86 @@ +import { useCallback, useMemo, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { SizedWebView } from 'react-native-sized-webview'; + +import { PillButton } from '../components/PillButton'; +import { SectionHeader } from '../components/SectionHeader'; +import { DEFAULT_REMOTE_PAGE_ID, REMOTE_PAGES } from '../data/remotePages'; +import { colors, radius, spacing } from '../styles/theme'; + +/** + * Live remote-website demo. Tapping a pill swaps the WebView source and the + * height resolves automatically — proving that the multi-source measurement + * algorithm copes with arbitrary CMS-driven pages. + */ +export const RemoteSitePicker = () => { + const [selectedId, setSelectedId] = useState(DEFAULT_REMOTE_PAGE_ID); + const [height, setHeight] = useState(null); + + const source = useMemo(() => { + const page = REMOTE_PAGES.find((entry) => entry.id === selectedId); + return page ? { uri: page.uri } : undefined; + }, [selectedId]); + + const handleSelect = useCallback((id: string) => { + setSelectedId(id); + setHeight(null); + }, []); + + return ( + + + + + {REMOTE_PAGES.map((page) => ( + handleSelect(page.id)} + /> + ))} + + + {source ? ( + + ) : null} + + + {height == null + ? 'Waiting for remote content to size…' + : `Rendered height: ${Math.round(height).toLocaleString()} dp`} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: spacing.lg, + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + webview: { + borderRadius: radius.card, + overflow: 'hidden', + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + hint: { + fontSize: 13, + color: colors.textMuted, + }, +}); diff --git a/example/src/styles/theme.ts b/example/src/styles/theme.ts new file mode 100644 index 0000000..2cb1c36 --- /dev/null +++ b/example/src/styles/theme.ts @@ -0,0 +1,33 @@ +/** + * Shared visual tokens (colors, spacing, radius) used across the example app. + * + * Centralising these values keeps the demo screens DRY and ensures every + * section renders with a consistent visual language. + */ + +export const colors = { + bg: '#f8fafc', + bgDark: '#0f172a', + surface: '#ffffff', + border: '#cbd5f5', + borderActive: '#2563eb', + bgActive: '#dbeafe', + textActive: '#1d4ed8', + textPrimary: '#0f172a', + textSecondary: '#475569', + textMuted: '#64748b', + textBody: '#1e293b', +} as const; + +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, +} as const; + +export const radius = { + card: 12, + pill: 999, +} as const; diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 8ae3f3c..55950f8 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -86,7 +86,7 @@ describe('SizedWebView', () => { ); expect(props.injectedJavaScriptBeforeContentLoaded).toBe(bridgeScript); expect(props.injectedJavaScript).toBe( - composeInjectedScript("console.log('after');") + composeInjectedScript(AUTO_HEIGHT_BRIDGE, "console.log('after');") ); act(() => { diff --git a/src/__tests__/useAutoHeight.test.tsx b/src/__tests__/useAutoHeight.test.tsx index 32a16b8..575b3ab 100644 --- a/src/__tests__/useAutoHeight.test.tsx +++ b/src/__tests__/useAutoHeight.test.tsx @@ -136,6 +136,35 @@ describe('useAutoHeight', () => { unmount(); }); + it('rejects prefixed payloads that are not plain decimal numbers', () => { + const { unmount } = render( + + ); + + // Hex, exponential, leading/trailing whitespace, and signed values must + // all be treated as forged input — the bridge only emits plain decimals. + const forgedPayloads = [ + '__RN_SIZED_WV__:0x100', + '__RN_SIZED_WV__:1e10', + '__RN_SIZED_WV__: 360 ', + '__RN_SIZED_WV__:+360', + '__RN_SIZED_WV__:-360', + '__RN_SIZED_WV__:NaN', + '__RN_SIZED_WV__:Infinity', + ]; + + for (const payload of forgedPayloads) { + act(() => { + latest.setHeightFromPayload(payload); + }); + } + + expect(requestAnimationFrameMock).not.toHaveBeenCalled(); + expect(latest.height).toBeUndefined(); + expect(onHeightChange).not.toHaveBeenCalled(); + unmount(); + }); + it('ignores invalid or insignificant height updates', () => { const { unmount } = render( diff --git a/src/components/SizedWebView.tsx b/src/components/SizedWebView.tsx index 9fe069e..d4fce62 100644 --- a/src/components/SizedWebView.tsx +++ b/src/components/SizedWebView.tsx @@ -83,13 +83,44 @@ const TRANSPARENT_WEBVIEW_STYLE = { backgroundColor: 'transparent' as const }; /** * A `react-native-webview` that sizes itself to match its rendered HTML. * + * @remarks * - Respects `javaScriptEnabled={false}` (auto-sizing is skipped, `minHeight` * or `containerStyle.height` becomes the authoritative height). - * - Uses a namespaced `onMessage` protocol so user-land messages never - * collide with the internal height bridge. + * - Uses a namespaced `onMessage` protocol (`__RN_SIZED_WV__:`) so + * user-land messages never collide with the internal height bridge. * - Returns `undefined` for the container height until the first valid * measurement when `minHeight === 0`, avoiding the iOS 26 WKWebView * feedback loop that collapses content to 1px. + * - The injected bridge measures via the `Math.max` of multiple authoritative + * layout sources (`scrollHeight`, `offsetHeight`, last-child + * `getBoundingClientRect().bottom + marginBottom`) so it never under-reports + * on iOS WKWebView even with margin-collapse, late image reflow, or async + * web fonts. + * + * @example + * ```tsx + * import { SizedWebView } from 'react-native-sized-webview'; + * + * export function ArticleBody({ html }: { html: string }) { + * return ( + * console.log('measured', h)} + * /> + * ); + * } + * ``` + * + * @example Inside a ScrollView with a centered loading state: + * ```tsx + * + * + * + * ``` */ const SizedWebViewImpl = (props: SizedWebViewProps) => { const { @@ -131,6 +162,18 @@ const SizedWebViewImpl = (props: SizedWebViewProps) => { [isJsEnabled, onMessage, setHeightFromPayload] ); + // The bridge is intentionally injected at BOTH lifecycle hooks: + // + // - `injectedJavaScriptBeforeContentLoaded` runs at WKUserScriptInjectionTimeAtDocumentStart + // so observers/styles are wired up before any user CSS or scripts can + // interfere. This is the preferred path on Android. + // - `injectedJavaScript` runs after the document loads and is the only + // reliable path on iOS WKWebView for inline `source.html` payloads — + // `injectedJavaScriptBeforeContentLoaded` is documented to occasionally + // miss `loadHTMLString:baseURL:` loads (react-native-webview#1498). + // + // The bridge is idempotent: a second injection finds the frozen global + // handle, calls `refresh()` to re-run measurement, and returns. const composedBeforeContentScript = useMemo( () => composeInjectedScript( @@ -141,8 +184,12 @@ const SizedWebViewImpl = (props: SizedWebViewProps) => { ); const composedInjectedScript = useMemo( - () => composeInjectedScript(injectedJavaScript), - [injectedJavaScript] + () => + composeInjectedScript( + isJsEnabled ? AUTO_HEIGHT_BRIDGE : undefined, + injectedJavaScript + ), + [isJsEnabled, injectedJavaScript] ); const containerStyles = useMemo>(() => { @@ -176,6 +223,11 @@ const SizedWebViewImpl = (props: SizedWebViewProps) => { SizedWebViewImpl.displayName = 'SizedWebView'; /** - * Memoized `SizedWebView`. See {@link SizedWebViewProps} for configuration. + * Memoized `SizedWebView` — a drop-in replacement for `WebView` from + * `react-native-webview` that auto-sizes its container to the rendered HTML. + * + * @see {@link SizedWebViewProps} for the full prop reference. + * @see {@link useAutoHeight} if you need the bare height-tracking hook + * without the component shell. */ export const SizedWebView = memo(SizedWebViewImpl); diff --git a/src/constants/autoHeightBridge.ts b/src/constants/autoHeightBridge.ts index cf350bd..b2f67e4 100644 --- a/src/constants/autoHeightBridge.ts +++ b/src/constants/autoHeightBridge.ts @@ -4,20 +4,61 @@ * The script is idempotent (safe to inject multiple times), uses a namespaced * `postMessage` protocol (`__RN_SIZED_WV__:`) so user-land messages * never collide with bridge traffic, and avoids clamping the host document's - * layout until a real height has been committed — a behavior required for + * layout until a real height has been committed — a behaviour required for * correct rendering inside iOS 26 WKWebView. + * + * @remarks + * ## Measurement algorithm (O(1)) + * + * Every measurement is the `Math.max` of multiple authoritative layout + * sources, **without mutating** the host page's DOM or styles: + * + * 1. `body.scrollHeight` / `body.offsetHeight` — primary signal; includes + * body padding and any block-level margin that did not collapse out. + * 2. `documentElement.scrollHeight` / `documentElement.offsetHeight` — + * backstop when framework CSS rules style `html` directly. + * 3. `body.lastInFlowChild.getBoundingClientRect().bottom + + * computedMarginBottom` — catches margin-collapse (where the last + * child's bottom margin escapes ``) and late-reflow scenarios + * where `scrollHeight` momentarily under-reports on iOS WKWebView. + * `getBoundingClientRect` is part of the CSSOM View spec and returns + * document-layout coordinates, NOT viewport-clamped values. + * + * Inert siblings (`SCRIPT`, `STYLE`, `META`, `LINK`, `TITLE`, `HEAD`, + * `NOSCRIPT`) and out-of-flow positions (`fixed` / `sticky` / `absolute`) + * are skipped during the last-child walk so they never short-circuit the + * probe with viewport-clamped or zero-height values. + * + * **No DOM mutation:** earlier versions wrapped ``'s children in a + * synthetic `
` for measurement, which broke margin collapse between + * the first/last child and the body and caused under-reporting on + * margin-heavy CMS content. The bridge now measures the user's DOM + * directly and never injects styles. + * + * ## Fallback strategy + * + * Measurement is rerun adaptively while either condition holds: + * + * - `state.pendingLoads > 0` (an image / iframe / video is still loading), or + * - `Date.now() - state.bootstrapAt < BOOTSTRAP_GRACE_MS` (5 s grace window + * from script start, refreshed on `markLoading`, font `loadingdone`, and + * `state.refresh`). + * + * Once both expire only signal-driven re-measures (mutation, resize, font, + * viewport, message) trigger work — the steady-state CPU cost is zero. */ export const AUTO_HEIGHT_BRIDGE: string = `(() => { + // ============================================================ + // SECTION: Constants + // ============================================================ var GLOBAL_KEY = '__RN_SIZED_WEBVIEW__'; - var WRAPPER_ID = '__RN_SIZED_WEBVIEW_WRAPPER__'; - var TRACKED_FLAG = '__RN_SIZED_WEBVIEW_MEDIA__'; var MESSAGE_KEY = '__AUTO_HEIGHT__'; var MESSAGE_PREFIX = '__RN_SIZED_WV__:'; var ACTIVE_DEBOUNCE_MS = 48; var IDLE_DEBOUNCE_MS = 160; var INITIAL_FALLBACK_MS = 600; var MAX_FALLBACK_MS = 4000; - var MAX_FALLBACK_ITERATIONS = 8; + var BOOTSTRAP_GRACE_MS = 5000; var MAX_REASONABLE_HEIGHT = 120000; var WARMUP_MIN_HEIGHT = 2; @@ -25,9 +66,15 @@ export const AUTO_HEIGHT_BRIDGE: string = `(() => { return; } + // The global handle exposes ONLY a tiny frozen surface (refresh/destroy/ + // version) — the mutable internal \`state\` stays in closure so page scripts + // cannot tamper with counters, timers, or pending-load flags. if (window[GLOBAL_KEY]) { try { - window[GLOBAL_KEY].refresh(); + var existing = window[GLOBAL_KEY]; + if (existing && typeof existing.refresh === 'function') { + existing.refresh(); + } } catch (error) { // no-op } @@ -54,6 +101,9 @@ export const AUTO_HEIGHT_BRIDGE: string = `(() => { }; }; + // ============================================================ + // SECTION: State + // ============================================================ var state = { frame: null, timer: null, @@ -63,18 +113,55 @@ export const AUTO_HEIGHT_BRIDGE: string = `(() => { anomalyCount: 0, fallbackTimer: null, fallbackDelay: INITIAL_FALLBACK_MS, - fallbackCount: 0, + // Timestamp of the most recent "bootstrap signal" (script start, refresh, + // markLoading, font loadingdone). Within BOOTSTRAP_GRACE_MS of this value + // the fallback timer keeps re-arming itself adaptively. Replaces the + // bounded fallbackCount strategy that could exhaust before slow CMS pages + // finished settling. + bootstrapAt: Date.now(), cleanup: [], - wrapper: null, mediaObserver: null, - // Dirty flag: set to true whenever the DOM mutates so that the next - // measure runs pruneTrailingNodes. Cleared after each prune pass. This - // skips the recursive hasRenderableContent DFS on every rAF tick during - // resize / font-load storms when no structural mutation has happened. - domDirty: true, }; - window[GLOBAL_KEY] = state; + var publishHandle = function () { + var handle = { + version: 2, + refresh: function () { + state.bootstrapAt = Date.now(); + scheduleMeasure(true); + }, + destroy: function () { + cleanupAll(); + }, + }; + + try { + // Lock down the public handle so page scripts cannot replace methods + // with no-ops. Object.freeze is supported on every WebView platform we + // target; defineProperty hardens the slot itself against reassignment. + if (typeof Object.freeze === 'function') { + Object.freeze(handle); + } + if (typeof Object.defineProperty === 'function') { + Object.defineProperty(window, GLOBAL_KEY, { + value: handle, + writable: false, + configurable: false, + enumerable: false, + }); + } else { + window[GLOBAL_KEY] = handle; + } + } catch (error) { + // Property may already be locked or defineProperty may be missing on + // very old engines — fall back to a plain assignment. + try { + window[GLOBAL_KEY] = handle; + } catch (innerError) { + // no-op + } + } + }; var requestFrame = function (callback) { if (typeof window.requestAnimationFrame === 'function') { @@ -127,18 +214,25 @@ export const AUTO_HEIGHT_BRIDGE: string = `(() => { } state.cleanup.length = 0; - state.wrapper = null; state.mediaObserver = null; - window[GLOBAL_KEY] = undefined; - }; - - state.refresh = function () { - state.fallbackCount = 0; - ensureWrapper(); - scheduleMeasure(true); + // Best-effort: leave the frozen handle in place when defineProperty made + // it non-configurable. Subsequent re-injections see it and short-circuit. + try { + if ( + typeof Object.getOwnPropertyDescriptor === 'function' && + Object.getOwnPropertyDescriptor(window, GLOBAL_KEY) && + Object.getOwnPropertyDescriptor(window, GLOBAL_KEY).configurable + ) { + window[GLOBAL_KEY] = undefined; + } + } catch (error) { + // no-op + } }; - state.destroy = cleanupAll; + // Hoisted so cleanupAll above can reference it without TDZ issues; the body + // simply forwards into closure-private state mutation. + // (No-op placeholder — real definition lives below.) var addEvent = function (target, type, handler, options) { if (!target || typeof target.addEventListener !== 'function') { @@ -174,14 +268,9 @@ export const AUTO_HEIGHT_BRIDGE: string = `(() => { return remove; }; - var scheduleTimeout = function (callback, delay) { - var id = window.setTimeout(callback, delay); - addCleanup(function () { - clearTimeout(id); - }); - return id; - }; - + // ============================================================ + // SECTION: Content classification + // ============================================================ var RENDERABLE_MEDIA_TAGS = { IMG: true, IFRAME: true, @@ -194,106 +283,35 @@ export const AUTO_HEIGHT_BRIDGE: string = `(() => { AUDIO: true, }; - var hasMeaningfulText = function (text) { - if (!text) { - return false; - } - - // String.prototype.trim treats non-breaking spaces as empty, even though - // HTML editors commonly use   as visible layout content. - return text.replace(/[\t\n\f\r ]+/g, '').length > 0; - }; - - var hasRenderableContent = function (node) { - if (!node || !node.childNodes || !node.childNodes.length) { - return false; - } - - var child = node.firstChild; - while (child) { - if (child.nodeType === 3) { - if (hasMeaningfulText(child.textContent)) { - return true; - } - } else if (child.nodeType === 1) { - var tag = (child.tagName || '').toUpperCase(); - if (tag === 'BR') { - child = child.nextSibling; - continue; - } - - if (RENDERABLE_MEDIA_TAGS[tag]) { - return true; - } - - if (hasRenderableContent(child)) { - return true; - } - } - - child = child.nextSibling; - } - - return false; + // Inert tags — never contribute to layout height. Skipped by the last-child + // walk in measureHeight() so a trailing