Skip to content

Commit de82f36

Browse files
mCodexCopilot
andcommitted
feat: add loadingContainerStyle prop for improved loading state handling
Co-authored-by: Copilot <copilot@github.com>
1 parent 2768a67 commit de82f36

4 files changed

Lines changed: 121 additions & 190 deletions

File tree

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Three defaults changed in the 1.1.x line. Each is a one-line migration:
3939
- 🛡️ Sanity guard clamps runaway heights and retries with the last good value, so flaky pages never lock your layout.
4040
- 🧵 Keeps the WebView scroll-disabled so outer `ScrollView`s and gesture handlers stay silky smooth.
4141
- 🎨 Transparent background by default; style the container however you like.
42-
- ⚙️ Friendly API with `minHeight`, `containerStyle`, and `onHeightChange` callbacks.
42+
- ⚙️ Friendly API with `minHeight`, `containerStyle`, `loadingContainerStyle`, and `onHeightChange` callbacks.
4343
- 🌲 ESM-first build, fully typed, `sideEffects: false` for optimal tree shaking.
4444
- 📱 Verified on iOS, Android, and Expo Go out of the box.
4545

@@ -100,6 +100,7 @@ The example showcases:
100100
| --- | --- | --- | --- |
101101
| `minHeight` | `number` | `0` | Minimum height (dp) applied to the container. When `0`, the container is unsized until the first measurement arrives (avoids layout flicker and the iOS 26 WKWebView 1px feedback loop). |
102102
| `containerStyle` | `StyleProp<ViewStyle>` || Styles applied to the wrapping `View`. Use it for padding, borders, or shadows. Do not set `height` — it is managed by the hook. |
103+
| `loadingContainerStyle` | `StyleProp<ViewStyle>` || Styles applied to the wrapping `View` **only while height is still being measured** (i.e. while the bridge has not yet reported a value). Use `{ flex: 1 }` inside a `ScrollView` with `contentContainerStyle={{ flexGrow: 1 }}` so the native activity indicator is never clipped. Dropped automatically once the first measurement commits. |
103104
| `onHeightChange` | `(height: number) => void` || Callback fired whenever a new height is committed. Great for analytics or debugging. Never fires for invalid or out-of-range values. |
104105
| `originWhitelist` | `string[]` | `['http://*', 'https://*']` | Origins the WebView is allowed to navigate to. Blocks non-web schemes (`file:`, `javascript:`, `data:`, `intent:`) by default. Tighten it to a specific origin list for stricter environments. |
105106
| `javaScriptEnabled` | `boolean` | `true` | When `false`, the auto-height bridge is **not** injected and the container falls back to `minHeight`. Use for static HTML that doesn't need JS. |
@@ -108,6 +109,26 @@ The example showcases:
108109
> [!NOTE]
109110
> 🧩 `scrollEnabled` defaults to `false` so sizing remains deterministic. Only enable it if the WebView should manage its own scroll.
110111
112+
### Loading state inside a `ScrollView`
113+
114+
When `minHeight` is `0` (the default), the container has no height until the bridge reports the first measurement. Inside a `ScrollView` this means the native loading spinner is rendered in a 0 dp frame and gets clipped.
115+
116+
Fix it with `loadingContainerStyle`:
117+
118+
```tsx
119+
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
120+
<SizedWebView
121+
source={{ uri: 'https://example.com/article' }}
122+
loadingContainerStyle={{ flex: 1 }}
123+
containerStyle={{ borderRadius: 12, overflow: 'hidden' }}
124+
renderLoading={() => <ActivityIndicator size="large" style={{ flex: 1 }} />}
125+
/>
126+
</ScrollView>
127+
```
128+
129+
- During loading: container is `[{ flex: 1 }, containerStyle]` — fills the scroll area, spinner is fully visible.
130+
- After measurement: container becomes `[{ height: N }, containerStyle]` — shrinks to content height, `loadingContainerStyle` is dropped.
131+
111132
## 🛡️ Security
112133

113134
- **Namespaced message protocol.** The injected bridge posts values prefixed with `__RN_SIZED_WV__:` and the hook rejects everything else, so your own `onMessage` traffic cannot accidentally (or maliciously) mutate the container height.

src/__tests__/index.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ describe('SizedWebView', () => {
7373

7474
expect(props.style).toEqual([
7575
{ backgroundColor: 'transparent' },
76-
{ flex: 1, width: '100%' },
7776
{ opacity: 0.5 },
7877
]);
7978
expect(props.originWhitelist).toEqual(['http://*', 'https://*']);
@@ -291,4 +290,53 @@ describe('SizedWebView', () => {
291290
renderResult.unmount();
292291
});
293292
});
293+
294+
it('applies loadingContainerStyle to the container while height is undefined', () => {
295+
const { useAutoHeight, __setHeightFromPayload } = jest.requireMock(
296+
'../hooks/useAutoHeight'
297+
);
298+
(useAutoHeight as jest.Mock).mockReturnValue({
299+
height: undefined,
300+
setHeightFromPayload: __setHeightFromPayload,
301+
});
302+
303+
const renderResult = render(
304+
<SizedWebView
305+
source={{ html: '<p>Hi</p>' }}
306+
loadingContainerStyle={{ flex: 1 }}
307+
containerStyle={{ backgroundColor: 'blue' }}
308+
/>
309+
);
310+
311+
const container = renderResult.UNSAFE_getByType(View);
312+
expect(container.props.style).toEqual([
313+
{ flex: 1 },
314+
{ backgroundColor: 'blue' },
315+
]);
316+
317+
act(() => {
318+
renderResult.unmount();
319+
});
320+
});
321+
322+
it('does not include loadingContainerStyle once height is committed', () => {
323+
const renderResult = render(
324+
<SizedWebView
325+
source={{ html: '<p>Hi</p>' }}
326+
loadingContainerStyle={{ flex: 1 }}
327+
containerStyle={{ backgroundColor: 'green' }}
328+
/>
329+
);
330+
331+
// Default mock returns height: 240 — measurement is already committed.
332+
const container = renderResult.UNSAFE_getByType(View);
333+
expect(container.props.style).toEqual([
334+
{ height: 240 },
335+
{ backgroundColor: 'green' },
336+
]);
337+
338+
act(() => {
339+
renderResult.unmount();
340+
});
341+
});
294342
});

src/components/SizedWebView.tsx

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,29 @@ export interface SizedWebViewProps extends WebViewProps {
3030
*/
3131
containerStyle?: StyleProp<ViewStyle>;
3232

33+
/**
34+
* Style applied to the wrapping `View` only while the content height is
35+
* still being measured (i.e. while `height` is `undefined`).
36+
*
37+
* Use this to keep the container visible during loading so the native
38+
* activity indicator is never clipped. A common pattern:
39+
*
40+
* ```tsx
41+
* // Inside a ScrollView with contentContainerStyle={{ flexGrow: 1 }}
42+
* <SizedWebView
43+
* source={source}
44+
* loadingContainerStyle={{ flex: 1 }}
45+
* />
46+
* ```
47+
*
48+
* Once the first height measurement is committed, this style is dropped and
49+
* `containerStyle` + the measured `{ height }` take full effect. Setting
50+
* this on the **outer container** (not the inner WebView) is safe — the
51+
* injected wrapper `div` has `height: auto` and no `overflow: hidden`, so
52+
* `wrapper.scrollHeight` is never clamped by the native frame size.
53+
*/
54+
loadingContainerStyle?: StyleProp<ViewStyle>;
55+
3356
/**
3457
* Fires after each committed auto-height change. Values are rAF-batched and
3558
* clamped to a sane upper bound for safety.
@@ -56,7 +79,6 @@ const WEBVIEW_DEFAULTS = {
5679
} satisfies Partial<WebViewProps>;
5780

5881
const TRANSPARENT_WEBVIEW_STYLE = { backgroundColor: 'transparent' as const };
59-
const MEASURED_WEBVIEW_STYLE = { flex: 1, width: '100%' as const };
6082

6183
/**
6284
* A `react-native-webview` that sizes itself to match its rendered HTML.
@@ -73,6 +95,7 @@ const SizedWebViewImpl = (props: SizedWebViewProps) => {
7395
const {
7496
minHeight = 0,
7597
containerStyle,
98+
loadingContainerStyle,
7699
style,
77100
injectedJavaScript,
78101
injectedJavaScriptBeforeContentLoaded,
@@ -124,18 +147,16 @@ const SizedWebViewImpl = (props: SizedWebViewProps) => {
124147

125148
const containerStyles = useMemo<StyleProp<ViewStyle>>(() => {
126149
if (height == null) {
127-
return containerStyle;
150+
return loadingContainerStyle != null
151+
? [loadingContainerStyle, containerStyle]
152+
: containerStyle;
128153
}
129154
return [{ height }, containerStyle];
130-
}, [containerStyle, height]);
155+
}, [containerStyle, height, loadingContainerStyle]);
131156

132157
const webViewStyles = useMemo(
133-
() => [
134-
TRANSPARENT_WEBVIEW_STYLE,
135-
height == null ? undefined : MEASURED_WEBVIEW_STYLE,
136-
style,
137-
],
138-
[height, style]
158+
() => [TRANSPARENT_WEBVIEW_STYLE, style],
159+
[style]
139160
);
140161

141162
return (

0 commit comments

Comments
 (0)