Skip to content

Commit a82d1e0

Browse files
committed
Refine bridge docs and integer-only payload parsing
Clarify bridge behavior and measurement algorithm, and tighten payload parsing to ASCII-digit integers. Changes: - README: Explain the bridge is idempotent, measures the page in-place (no re-parenting or injected inline styles), update measurement sources order, and note skipping of inert and out-of-flow elements. Clarify iOS WKWebView/getBoundingClientRect behavior and bootstrap grace logic. - src/constants/autoHeightBridge.ts: Update comments to describe O(k) complexity, clarify getBoundingClientRect semantics, and remove an unused RENDERABLE_MEDIA_TAGS block from the injected bridge string. - src/hooks/useAutoHeight.ts: Only accept namespaced integer payloads from the bridge (changed regex from allowing decimals to /^ \d+$/) and update explanatory comments and example imports. - src/__tests__/useAutoHeight.test.tsx: Update test name/text to expect integer-only payloads and add decimal payloads to forged inputs. Rationale: Improve documentation accuracy about how the injected bridge operates, tighten input validation to reject fractional or otherwise coerced numeric payloads, and keep the injected script lean by removing unused tag metadata.
1 parent b8f69bf commit a82d1e0

4 files changed

Lines changed: 38 additions & 35 deletions

File tree

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,15 @@ Fix it with `loadingContainerStyle`:
149149

150150
## 🧠 How It Works
151151

152-
The injected bridge runs once per page, before content loads, and turns the WebView into a self-measuring component:
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:
153153

154-
- **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.
155-
- **Multi-source measurement (the production-grade fix).** Each measurement is the `Math.max` of four authoritative layout sources:
156-
1. `wrapper.scrollHeight` / `wrapper.offsetHeight` — primary, fastest, accurate for normal block flow.
157-
2. `document.body.scrollHeight` / `documentElement.scrollHeight` — backstop when frameworks style `body`/`html` directly.
158-
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.
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.
159159

160-
Inert siblings (`SCRIPT`, `STYLE`, `META`, `LINK`, `TITLE`, `HEAD`, `NOSCRIPT`) are skipped during the last-child walk so they never short-circuit the probe.
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.
161161
- **Bootstrap-grace adaptive fallback.** A timer re-arms itself only while either condition holds:
162162
- `pendingLoads > 0` (an image / iframe / video is still loading), **or**
163163
- `Date.now() - bootstrapAt < BOOTSTRAP_GRACE_MS` (5 s grace window from script start, refreshed on `markLoading`, font `loadingdone`, and external `state.refresh` calls).

src/__tests__/useAutoHeight.test.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,14 @@ describe('useAutoHeight', () => {
136136
unmount();
137137
});
138138

139-
it('rejects prefixed payloads that are not plain decimal numbers', () => {
139+
it('rejects prefixed payloads that are not plain integer numbers', () => {
140140
const { unmount } = render(
141141
<Harness minHeight={0} onHeightChange={onHeightChange} />
142142
);
143143

144-
// Hex, exponential, leading/trailing whitespace, and signed values must
145-
// all be treated as forged input — the bridge only emits plain decimals.
144+
// Hex, exponential, leading/trailing whitespace, signed, and decimal
145+
// values must all be treated as forged input — the bridge only emits
146+
// ASCII-digit integers (`String(Math.ceil(height))`).
146147
const forgedPayloads = [
147148
'__RN_SIZED_WV__:0x100',
148149
'__RN_SIZED_WV__:1e10',
@@ -151,6 +152,8 @@ describe('useAutoHeight', () => {
151152
'__RN_SIZED_WV__:-360',
152153
'__RN_SIZED_WV__:NaN',
153154
'__RN_SIZED_WV__:Infinity',
155+
'__RN_SIZED_WV__:360.5',
156+
'__RN_SIZED_WV__:.5',
154157
];
155158

156159
for (const payload of forgedPayloads) {

src/constants/autoHeightBridge.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
* correct rendering inside iOS 26 WKWebView.
99
*
1010
* @remarks
11-
* ## Measurement algorithm (O(1))
11+
* ## Measurement algorithm (O(k))
1212
*
1313
* Every measurement is the `Math.max` of multiple authoritative layout
14-
* sources, **without mutating** the host page's DOM or styles:
14+
* sources, **without mutating** the host page's DOM or styles. The cost is
15+
* O(k) where k is the number of trailing inert / out-of-flow siblings the
16+
* last-child walk has to skip (typically 0–2 — effectively constant in
17+
* steady state):
1518
*
1619
* 1. `body.scrollHeight` / `body.offsetHeight` — primary signal; includes
1720
* body padding and any block-level margin that did not collapse out.
@@ -21,8 +24,13 @@
2124
* computedMarginBottom` — catches margin-collapse (where the last
2225
* child's bottom margin escapes `<body>`) and late-reflow scenarios
2326
* where `scrollHeight` momentarily under-reports on iOS WKWebView.
24-
* `getBoundingClientRect` is part of the CSSOM View spec and returns
25-
* document-layout coordinates, NOT viewport-clamped values.
27+
* Per the CSSOM View spec, `getBoundingClientRect` returns
28+
* viewport-relative coordinates whose values are NOT clamped to the
29+
* visible viewport. We use the bottom value as a proxy for the
30+
* document's bottom edge under the assumption the page itself is not
31+
* internally scrolled during measurement — a safe assumption since the
32+
* host RN component sets `scrollEnabled={false}` and the bridge never
33+
* triggers programmatic scrolling.
2634
*
2735
* Inert siblings (`SCRIPT`, `STYLE`, `META`, `LINK`, `TITLE`, `HEAD`,
2836
* `NOSCRIPT`) and out-of-flow positions (`fixed` / `sticky` / `absolute`)
@@ -271,18 +279,6 @@ export const AUTO_HEIGHT_BRIDGE: string = `(() => {
271279
// ============================================================
272280
// SECTION: Content classification
273281
// ============================================================
274-
var RENDERABLE_MEDIA_TAGS = {
275-
IMG: true,
276-
IFRAME: true,
277-
VIDEO: true,
278-
SVG: true,
279-
CANVAS: true,
280-
PICTURE: true,
281-
OBJECT: true,
282-
EMBED: true,
283-
AUDIO: true,
284-
};
285-
286282
// Inert tags — never contribute to layout height. Skipped by the last-child
287283
// walk in measureHeight() so a trailing <script>/<style> never fools the
288284
// position-based probe into reporting 0.
@@ -335,9 +331,11 @@ export const AUTO_HEIGHT_BRIDGE: string = `(() => {
335331
*
336332
* The position-based probe catches cases where scrollHeight under-reports
337333
* (margin-collapse where the last child's bottom margin escapes \`<body>\`,
338-
* late image reflow, etc.). It is spec-correct on both iOS WKWebView and
339-
* Android WebView — \`getBoundingClientRect\` returns document-layout
340-
* coordinates, not viewport-clamped values.
334+
* late image reflow, etc.). \`getBoundingClientRect\` returns
335+
* viewport-relative coordinates per CSSOM View, but those values are not
336+
* clamped to the visible viewport — and the host RN component sets
337+
* \`scrollEnabled={false}\` so the WebView is never internally scrolled,
338+
* making the bottom value a reliable proxy for the document's bottom edge.
341339
*
342340
* Complexity: O(k) where k is the number of trailing inert / out-of-flow
343341
* siblings (typically 0–2). Single layout flush per call.

src/hooks/useAutoHeight.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,19 @@ const HEIGHT_DIFF_THRESHOLD = 1;
6060
* Accepts:
6161
* - `number` values (direct/programmatic calls — never reach the WebView).
6262
* - Strings starting with {@link BRIDGE_MESSAGE_PREFIX} whose suffix is a
63-
* plain decimal number (the only shape the bridge ever emits).
63+
* non-empty run of ASCII digits (the only shape the bridge ever emits:
64+
* `MESSAGE_PREFIX + String(Math.ceil(height))`).
6465
*
6566
* Bare numeric strings (e.g. `'360'`) are rejected: only the namespaced
6667
* bridge protocol is trusted, so user-land `postMessage('123')` cannot mutate
6768
* the container height.
6869
*
69-
* Hex (`0x100`), exponential (`1e10`), and whitespace-padded inputs are also
70-
* rejected — `Number()` would silently coerce them, but the bridge never
71-
* produces such payloads, so anything matching those shapes is treated as a
72-
* tampered/forged message.
70+
* Decimals (`12.5`), hex (`0x100`), exponential (`1e10`), and
71+
* whitespace-padded inputs are also rejected — `Number()` would silently
72+
* coerce them, but the bridge never produces such payloads, so anything
73+
* matching those shapes is treated as a tampered/forged message.
7374
*/
74-
const BRIDGE_NUMBER_PATTERN = /^\d+(?:\.\d+)?$/;
75+
const BRIDGE_NUMBER_PATTERN = /^\d+$/;
7576

7677
const parseHeightPayload = (rawValue: unknown): number | null => {
7778
let numericValue: number;
@@ -121,6 +122,7 @@ const parseHeightPayload = (rawValue: unknown): number | null => {
121122
*
122123
* @example
123124
* ```tsx
125+
* import { View } from 'react-native';
124126
* import { WebView } from 'react-native-webview';
125127
* import { AUTO_HEIGHT_BRIDGE, useAutoHeight } from 'react-native-sized-webview';
126128
*

0 commit comments

Comments
 (0)