diff --git a/README.md b/README.md index 2f1d241..8e0a2e3 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 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 `
` 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 @@ -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 = ` - - - - - - -
- 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 = ` -- The component listens to mutations and resizes using requestAnimationFrame to avoid - blocking the main thread. -
-
+ 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 = ` ++ The component listens to mutations and resizes using requestAnimationFrame to avoid + blocking the main thread. +
+
+ 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.
+
+ 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. +
+
+ The wrapper div has no explicit height and no overflow: hidden, so the
+ injected script never clamps the document to the native viewport.
+
+ scrollHeight in CSS pixels.getBoundingClientRect.
+ End of article — the trailing margin below this paragraph is included in the
+ committed height thanks to the computedMarginBottom probe.
+
', `${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 = ` + +
+ + + +
+