Skip to content

Commit 80773f8

Browse files
authored
Merge pull request #5 from mCodex/refactor/core
Add example app and harden auto-height bridge
2 parents cd97463 + a82d1e0 commit 80773f8

20 files changed

Lines changed: 1136 additions & 576 deletions

README.md

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,17 @@ yarn
8484
yarn example ios # or yarn example android
8585
```
8686

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

89-
- Auto-sizing dynamic HTML with toggled sections.
90-
- Live external sites (Marvel, NFL, Google, Wikipedia, The Verge) embedded without layout thrash.
91-
- Real-time height readouts so you can verify your own endpoints quickly.
92-
- One code path that works the same on iOS, Android, and Expo Go.
89+
1. **Local HTML demo** — toggle a switch to mutate the document and watch the WebView re-size live.
90+
2. **Remote site picker** — swap between Marvel, NFL, Google, Wikipedia, and The Verge to verify CMS-driven layouts resolve cleanly.
91+
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.
92+
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`).
93+
94+
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.
9395

9496
> [!NOTE]
95-
> 🧪 The demo is built with Expo; swap the `uri` to test your own pages instantly.
97+
> 🧪 The demo is built with Expo; swap any `uri` to test your own pages instantly.
9698
9799
## ⚙️ API
98100

@@ -147,12 +149,23 @@ Fix it with `loadingContainerStyle`:
147149

148150
## 🧠 How It Works
149151

150-
- 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.
151-
- Media events (images / iframes / video) trigger immediate + next-frame samples so late assets still report accurate heights.
152-
- Media elements stay observed via `ResizeObserver` + decode promises, catching intrinsic size changes without duplicate network requests.
153-
- Height calculations are debounced via `requestAnimationFrame` and a short idle timer to prevent resize storms.
154-
- Measurements arrive through `postMessage`, then `useAutoHeight` coalesces them into a single render per frame.
155-
- Package exports the bridge, hook, and helpers individually, making it easy to build bespoke wrappers when needed.
152+
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:
153+
154+
- **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).
155+
- **Multi-source measurement (the production-grade fix).** Each measurement is the `Math.max` of authoritative layout sources:
156+
1. `document.body.scrollHeight` / `document.body.offsetHeight` — primary document metrics for normal block flow.
157+
2. `document.documentElement.scrollHeight` / `document.documentElement.offsetHeight` — backstop when frameworks style `html` directly or when the root box exceeds `body`.
158+
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.
159+
160+
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.
161+
- **Bootstrap-grace adaptive fallback.** A timer re-arms itself only while either condition holds:
162+
- `pendingLoads > 0` (an image / iframe / video is still loading), **or**
163+
- `Date.now() - bootstrapAt < BOOTSTRAP_GRACE_MS` (5 s grace window from script start, refreshed on `markLoading`, font `loadingdone`, and external `state.refresh` calls).
164+
165+
Once both expire only signal-driven re-measures (mutation, resize, font, viewport, message) trigger work — the steady-state CPU cost is zero.
166+
- **Signal-driven observers.** `MutationObserver`, `ResizeObserver`, `visualViewport`, font-load events, and a namespaced `postMessage` channel each schedule a single rAF-batched measure.
167+
- **Rendered through `useAutoHeight`.** Heights are validated, clamped to `MAX_COMMITTED_HEIGHT` (120 000 dp), diff-thresholded, and committed at most once per animation frame.
168+
- **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.
156169

157170
## ⚖️ Performance Snapshot
158171

@@ -173,10 +186,11 @@ Every hot path is designed to run at its theoretical complexity floor — no all
173186
| Message parsing (`useAutoHeight`) | **O(1)** | Namespaced-prefix check, single `Number()` coerce, constant-bound clamp. |
174187
| Height commit (rAF-batched) | **O(1)** amortized per frame | Sub-pixel diffs are dropped; at most one React render per animation frame. |
175188
| DOM mutation callback | **O(added nodes)** | Scans only each mutation's `addedNodes`, not the whole tree. Media elements are deduped via a `WeakSet`. |
176-
| `measureHeight` | **1 forced reflow / call** | Reads the wrapper element only — its box is authoritative because every `<body>` child lives inside it. |
189+
| `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). |
177190
| 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. |
191+
| 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. |
178192

179-
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.
193+
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.
180194

181195
### 📦 Bundle & tree-shaking
182196

example/babel.config.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@ const root = path.resolve(__dirname, '..');
77
module.exports = (api) => {
88
api.cache(true);
99

10-
return getConfig(
10+
const config = getConfig(
1111
{
1212
presets: ['babel-preset-expo'],
1313
},
1414
{ root, pkg }
1515
);
16+
17+
// React Compiler must run before other transforms. React 19 ships its own
18+
// runtime, so no `react-compiler-runtime` package is required.
19+
config.plugins = ['babel-plugin-react-compiler', ...(config.plugins ?? [])];
20+
21+
return config;
1622
};

example/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
},
2222
"private": true,
2323
"devDependencies": {
24+
"babel-plugin-react-compiler": "^1.0.0",
2425
"react-native-builder-bob": "^0.41.0",
2526
"react-native-monorepo-config": "^0.3.3"
2627
}

0 commit comments

Comments
 (0)