Skip to content

Commit 5a7c875

Browse files
committed
Guard bridge messages, optimize DOM measurements
Only accept the bridge namespaced payloads for height updates and forward other messages to consumers. SizedWebView now checks that JS messages are strings starting with the bridge prefix before calling setHeightFromPayload; unprefixed payloads are passed to the caller's onMessage. useAutoHeight.parseHeightPayload was tightened to accept only numbers or prefixed bridge strings (bare numeric strings are rejected). The injected AUTO_HEIGHT_BRIDGE script was optimized: added a domDirty flag (set by the MutationObserver and cleared after pruning) so pruneTrailingNodes runs only when structure changed; prefer the wrapper element as the single authoritative reflow target to avoid multiple forced layouts; and other small measurement-path simplifications to reduce reflows and work per frame. Documentation: README gains a “Built for speed” section describing hot-path complexity and rationale for the optimizations.
1 parent 1e1f6e7 commit 5a7c875

6 files changed

Lines changed: 124 additions & 37 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,21 @@ The example showcases:
142142
| Scrolling in parent `ScrollView` | Nested scroll can fight gestures | Parent retains full momentum and gesture priority |
143143

144144
Benchmarks were captured on CMS articles up to 3k words in a 60 fps RN dev build. The bridge batches DOM mutations so even long documents resize without thrashing the JS thread.
145+
146+
### 🏎️ Built for speed
147+
148+
Every hot path is designed to run at its theoretical complexity floor — no allocations in steady state, no repeated DOM walks, and at most one forced layout per measurement frame.
149+
150+
| Hot path | Complexity | Notes |
151+
| --- | --- | --- |
152+
| Message parsing (`useAutoHeight`) | **O(1)** | Namespaced-prefix check, single `Number()` coerce, constant-bound clamp. |
153+
| Height commit (rAF-batched) | **O(1)** amortized per frame | Sub-pixel diffs are dropped; at most one React render per animation frame. |
154+
| DOM mutation callback | **O(added nodes)** | Scans only each mutation's `addedNodes`, not the whole tree. Media elements are deduped via a `WeakSet`. |
155+
| `measureHeight` | **1 forced reflow / call** | Reads the wrapper element only — its box is authoritative because every `<body>` child lives inside it. |
156+
| 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. |
157+
158+
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.
159+
145160
### 📦 Bundle & tree-shaking
146161

147162
- Ships as ESM-first (`lib/module/**`) with `"sideEffects": false`.

src/__tests__/index.test.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,13 @@ describe('SizedWebView', () => {
105105
);
106106

107107
const webViewProps = capturedWebViewProps.at(-1) ?? {};
108-
const event = { nativeEvent: { data: '360' } } as any;
108+
const event = { nativeEvent: { data: '__RN_SIZED_WV__:360' } } as any;
109109

110110
act(() => {
111111
(webViewProps.onMessage as (evt: unknown) => void)?.(event);
112112
});
113113

114-
expect(__setHeightFromPayload).toHaveBeenCalledWith('360');
114+
expect(__setHeightFromPayload).toHaveBeenCalledWith('__RN_SIZED_WV__:360');
115115
expect(onMessage).toHaveBeenCalledWith(event);
116116

117117
act(() => {
@@ -132,11 +132,11 @@ describe('SizedWebView', () => {
132132

133133
act(() => {
134134
(webViewProps.onMessage as (evt: unknown) => void)?.({
135-
nativeEvent: { data: '480' },
135+
nativeEvent: { data: '__RN_SIZED_WV__:480' },
136136
});
137137
});
138138

139-
expect(__setHeightFromPayload).toHaveBeenCalledWith('480');
139+
expect(__setHeightFromPayload).toHaveBeenCalledWith('__RN_SIZED_WV__:480');
140140

141141
act(() => {
142142
renderResult.unmount();
@@ -261,4 +261,33 @@ describe('SizedWebView', () => {
261261
renderResult.unmount();
262262
});
263263
});
264+
265+
it('does not forward unprefixed user-land messages to the auto-height hook', () => {
266+
const { __setHeightFromPayload } = jest.requireMock(
267+
'../hooks/useAutoHeight'
268+
);
269+
const onMessage = jest.fn();
270+
271+
const renderResult = render(
272+
<SizedWebView source={{ html: '<p>Hi</p>' }} onMessage={onMessage} />
273+
);
274+
275+
const webViewProps = capturedWebViewProps.at(-1) ?? {};
276+
const userLandEvent = {
277+
nativeEvent: { data: '400' },
278+
} as any;
279+
280+
act(() => {
281+
(webViewProps.onMessage as (evt: unknown) => void)?.(userLandEvent);
282+
});
283+
284+
// Bare numeric string is user-land traffic: must NOT reach the hook,
285+
// but MUST still be forwarded to the consumer's onMessage.
286+
expect(__setHeightFromPayload).not.toHaveBeenCalled();
287+
expect(onMessage).toHaveBeenCalledWith(userLandEvent);
288+
289+
act(() => {
290+
renderResult.unmount();
291+
});
292+
});
264293
});

src/__tests__/useAutoHeight.test.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('useAutoHeight', () => {
7373
expect(latest.height).toBe(120);
7474

7575
act(() => {
76-
latest.setHeightFromPayload('240');
76+
latest.setHeightFromPayload('__RN_SIZED_WV__:240');
7777
});
7878

7979
expect(requestAnimationFrameMock).toHaveBeenCalledTimes(1);
@@ -121,6 +121,21 @@ describe('useAutoHeight', () => {
121121
unmount();
122122
});
123123

124+
it('rejects bare numeric strings without the bridge prefix', () => {
125+
const { unmount } = render(
126+
<Harness minHeight={0} onHeightChange={onHeightChange} />
127+
);
128+
129+
act(() => {
130+
latest.setHeightFromPayload('400');
131+
});
132+
133+
expect(requestAnimationFrameMock).not.toHaveBeenCalled();
134+
expect(latest.height).toBeUndefined();
135+
expect(onHeightChange).not.toHaveBeenCalled();
136+
unmount();
137+
});
138+
124139
it('ignores invalid or insignificant height updates', () => {
125140
const { unmount } = render(
126141
<Harness minHeight={64} onHeightChange={onHeightChange} />

src/components/SizedWebView.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from 'react-native-webview';
99

1010
import { AUTO_HEIGHT_BRIDGE } from '../constants/autoHeightBridge';
11+
import { BRIDGE_MESSAGE_PREFIX } from '../constants/bridgeProtocol';
1112
import { useAutoHeight } from '../hooks/useAutoHeight';
1213
import { composeInjectedScript } from '../utils/composeInjectedScript';
1314

@@ -90,8 +91,16 @@ const SizedWebViewImpl = (props: SizedWebViewProps) => {
9091

9192
const handleMessage = useCallback(
9293
(event: WebViewMessageEvent) => {
93-
if (isJsEnabled) {
94-
setHeightFromPayload(event.nativeEvent.data);
94+
// Only bridge-prefixed strings can mutate the container height. Any
95+
// other payload (user-land `postMessage('hello')`, numeric strings from
96+
// the page, etc.) is forwarded untouched to the consumer's `onMessage`.
97+
const data = event.nativeEvent.data;
98+
if (
99+
isJsEnabled &&
100+
typeof data === 'string' &&
101+
data.startsWith(BRIDGE_MESSAGE_PREFIX)
102+
) {
103+
setHeightFromPayload(data);
95104
}
96105
onMessage?.(event);
97106
},

src/constants/autoHeightBridge.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ export const AUTO_HEIGHT_BRIDGE = `(() => {
7070
cleanup: [],
7171
wrapper: null,
7272
mediaObserver: null,
73+
// Dirty flag: set to true whenever the DOM mutates so that the next
74+
// measure runs pruneTrailingNodes. Cleared after each prune pass. This
75+
// skips the recursive hasRenderableContent DFS on every rAF tick during
76+
// resize / font-load storms when no structural mutation has happened.
77+
domDirty: true,
7378
};
7479
7580
window[GLOBAL_KEY] = state;
@@ -267,6 +272,9 @@ export const AUTO_HEIGHT_BRIDGE = `(() => {
267272
break;
268273
}
269274
275+
// Mark the subtree as clean until the next MutationObserver callback.
276+
state.domDirty = false;
277+
270278
if (removed) {
271279
scheduleMeasure(true);
272280
}
@@ -310,31 +318,28 @@ export const AUTO_HEIGHT_BRIDGE = `(() => {
310318
var html = document.documentElement;
311319
var body = document.body;
312320
var wrapper = ensureWrapper();
313-
var scrollingElement = document.scrollingElement;
314321
315-
pruneTrailingNodes(wrapper);
322+
// Only walk the trailing-node DFS when something actually mutated since
323+
// the last pass. Resize / font / viewport ticks don't change structure.
324+
if (state.domDirty) {
325+
pruneTrailingNodes(wrapper);
326+
}
316327
328+
// Fast path: when the wrapper exists (it wraps every body child) its
329+
// layout box is authoritative, so a single reflow via one element is
330+
// enough. Reading multiple elements forces a reflow per read. Only fall
331+
// back to html/body when the wrapper is unavailable.
317332
var targets = [];
318333
319334
if (wrapper) {
320335
targets.push(wrapper);
321-
}
322-
323-
if (body && targets.indexOf(body) === -1) {
324-
targets.push(body);
325-
}
326-
327-
if (html && targets.indexOf(html) === -1) {
328-
targets.push(html);
329-
}
330-
331-
if (
332-
scrollingElement &&
333-
scrollingElement !== body &&
334-
scrollingElement !== html &&
335-
targets.indexOf(scrollingElement) === -1
336-
) {
337-
targets.push(scrollingElement);
336+
} else {
337+
if (body) {
338+
targets.push(body);
339+
}
340+
if (html && html !== body) {
341+
targets.push(html);
342+
}
338343
}
339344
340345
if (!targets.length) {
@@ -773,6 +778,7 @@ export const AUTO_HEIGHT_BRIDGE = `(() => {
773778
}
774779
775780
var mutationObserver = new MutationObserver(function (mutations) {
781+
state.domDirty = true;
776782
requestDebouncedMeasure();
777783
778784
if (!state.wrapper || !document.contains(state.wrapper)) {

src/hooks/useAutoHeight.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ export interface UseAutoHeightResult {
4040
/**
4141
* Handler for raw `onMessage` payloads from `react-native-webview`.
4242
*
43-
* Accepts either the namespaced string the bridge emits
44-
* (`"__RN_SIZED_WV__:<number>"`) or a bare numeric value (kept for backward
45-
* compatibility with tests and custom integrations). Invalid, out-of-range,
46-
* or sub-threshold values are silently ignored.
43+
* Accepts only the namespaced string the bridge emits
44+
* (`"__RN_SIZED_WV__:<number>"`) or a raw `number` (useful for direct /
45+
* programmatic calls from tests and custom integrations). Bare numeric
46+
* strings, invalid values, and out-of-range values are silently ignored —
47+
* this is what prevents user-land `postMessage('123')` from mutating the
48+
* container height.
4749
*/
4850
setHeightFromPayload: (rawValue: unknown) => void;
4951
}
@@ -53,17 +55,28 @@ const HEIGHT_DIFF_THRESHOLD = 1;
5355

5456
/**
5557
* Parses a raw payload into a positive finite pixel count, or `null` if the
56-
* value is unusable. Accepts the namespaced protocol string, plain numbers,
57-
* and numeric strings.
58+
* value is unusable.
59+
*
60+
* Accepts:
61+
* - `number` values (direct/programmatic calls — never reach the WebView).
62+
* - Strings starting with {@link BRIDGE_MESSAGE_PREFIX} (bridge traffic).
63+
*
64+
* Bare numeric strings (e.g. `'360'`) are rejected: only the namespaced
65+
* bridge protocol is trusted, so user-land `postMessage('123')` cannot mutate
66+
* the container height.
5867
*/
5968
const parseHeightPayload = (rawValue: unknown): number | null => {
60-
let candidate: unknown = rawValue;
69+
let candidate: unknown;
6170

62-
if (
63-
typeof candidate === 'string' &&
64-
candidate.startsWith(BRIDGE_MESSAGE_PREFIX)
71+
if (typeof rawValue === 'number') {
72+
candidate = rawValue;
73+
} else if (
74+
typeof rawValue === 'string' &&
75+
rawValue.startsWith(BRIDGE_MESSAGE_PREFIX)
6576
) {
66-
candidate = candidate.slice(BRIDGE_MESSAGE_PREFIX.length);
77+
candidate = rawValue.slice(BRIDGE_MESSAGE_PREFIX.length);
78+
} else {
79+
return null;
6780
}
6881

6982
const numericValue =

0 commit comments

Comments
 (0)