Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `<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

Expand All @@ -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 `<body>` 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

Expand Down
8 changes: 7 additions & 1 deletion example/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading
Loading