Web Preview
A live page rendered inside the app (UC-CODE-03), native-substitute per the PRD verdict: the iframe is replaced by a native webview with the same URL bar and reload affordance. OPT-IN — the one item in the library that names a native module: react-native-webview is recorded here as an install-time dependency (AC-2's notice) and imported as a PEER in the source, while the registry package itself takes no dependency on it (types come from the ambient boundary declaration), so everything else installs and builds without it (AC-4). The console pane does not port and is dropped, per the verdict. The iframe sandbox has NO webview equivalent, so the lockdown is a fixed prop set at the render layer: originWhitelist http/https only, javaScriptEnabled pinned (the upstream sandbox's allow-scripts grant), file access and file-to-file universal access off, multiple windows off — and every commit is normalized then allowlisted BEFORE the webview sees it (web-preview.logic, Vitest-owned; scheme-less drafts gain https, loopback hosts gain http, javascript:/data:/file: are refused through onRefuse, never silently), with the body re-normalizing at render so the guard is not downgradable. AC-3: onError/onRenderProcessGone render an explicit failure panel — icon, title, failing host, Retry wired to the context reload — never a blank white view; HTTP error pages are content, exactly as the web iframe renders them. Upstream parts at parity (WebPreview/WebPreviewNavigation/WebPreviewNavigationButton/WebPreviewUrl/WebPreviewBody + the useWebPreview throw): the NavigationButton's tooltip becomes the accessibility label, the loading node overlays while the webview reports loading, Enter commits the URL bar (never per-keystroke), and the reload command rides the context because the web's consumer-wired iframe ref has no RN counterpart.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/web-preview.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/web-preview.jsonDependencies
react-native-webview
https://reactnativereusables.com/r/nativewind/button.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/input-group.json |
https://rnraielements.com/r/nativewind/url.json |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/input-group.json |
https://rnraielements.com/r/uniwind/url.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { InputGroup, InputGroupInput } from '@/registry/nativewind/components/ui/input-group';
import { Button } from '@/registry/nativewind/components/ui/button';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Text } from '@/registry/nativewind/components/ui/text';
import { hostnameOf } from '@/registry/nativewind/lib/url';
import { cn } from '@/registry/nativewind/lib/utils';
import { TriangleAlertIcon } from 'lucide-react-native';
import * as React from 'react';
import { View, type ViewProps } from 'react-native';
import WebView from 'react-native-webview';
import { normalizePreviewUrl } from './web-preview.logic';
/**
* WebPreview — a live page rendered inside the app (UC-CODE-03).
*
* THE PRD VERDICT IS NATIVE-SUBSTITUTE: "An iframe with a URL bar and console.
* Replaced by a native webview with the same URL bar and reload affordance, shipped as
* an opt-in registry entry that declares react-native-webview as a peer dependency.
* The console pane does not port and is dropped." Every clause, realized:
*
* - NATIVE WEBVIEW: the body is react-native-webview, imported as a PEER. The
* registry package itself takes NO dependency on it — not even for typecheck, which
* reads the ambient boundary declaration in types/rnr-boundary.d.ts instead — so a
* consumer who never installs this item never resolves the module (AC-4: the rest
* of the library builds without it). The emitted registry item records
* `dependencies: ["react-native-webview"]`, which is the at-install notice (AC-2).
*
* - THE SANDBOX MAPPING IS EXPLICIT (the UC-CODE-03 security scenario: "the iframe
* sandbox attribute has NO WebView equivalent"). Upstream hardcodes
* `sandbox="allow-scripts allow-same-origin allow-forms allow-popups
* allow-presentation"` on the iframe; a webview grants far more by default, so the
* guard is a fixed prop set, not a prop the caller can loosen:
* originWhitelist={['https://*', 'http://*']} → only web origins may be navigated
* (lib/url's allowlist, at the
* webview layer);
* javaScriptEnabled (pinned true) → the upstream sandbox's
* allow-scripts GRANT, pinned
* explicitly so a default flip
* upstream cannot silently
* change the posture — live
* pages need it (AC-1);
* allowFileAccess={false} → no file:// access;
* allowFileAccessFromFileURLs={false} +
* allowUniversalAccessFromFileURLs={false} → no file:// page reading other
* file:// content;
* setSupportMultipleWindows={false} → window.open cannot spawn windows
* (the Android z-order
* clickjacking vector; the
* "multiple-windows" lockdown).
* NOT portable and not claimed: the iframe's allow-top-navigation/modals denials —
* a webview IS the top level; alert()/confirm() render platform dialogs exactly as
* a non-sandboxed page would.
*
* - THE GUARD IS ALSO IN THE DATA: every commit passes normalizePreviewUrl
* (web-preview.logic, Vitest-owned) — scheme-less drafts gain https, loopback hosts
* gain http, and any javascript:/data:/file: draft is refused — and the BODY
* normalizes AGAIN at render, because the context can be fed from caller state.
* The check is never downgraded to a warning: a refused URL renders the empty
* placeholder, never a navigation. `onRefuse` on WebPreviewUrl reports the refusal
* (sources.tsx's never-silent law).
*
* - AC-3, FAILED LOAD IS EXPLICIT: onError and onRenderProcessGone render a failure
* panel — icon, "Preview failed to load", the failing host, and a Retry wired to
* the context's reload — never a blank white view. HTTP error PAGES (4xx/5xx
* bodies) are deliberately NOT failures: the web iframe renders them as content,
* and so does the webview (parity; wiring onHttpError would additionally
* false-fail on Android's per-subresource http-error callbacks).
*
* - SAME URL BAR, RELOAD AFFORDANCE: the upstream part set ports one-for-one —
* WebPreview (context state), WebPreviewNavigation, WebPreviewNavigationButton,
* WebPreviewUrl (placeholder "Enter URL...", Enter commits, derived-state sync),
* WebPreviewBody (the `loading` node). Two DECLARED divergences, both on the
* record: the web NavigationButton's tooltip becomes the accessibility `label`
* (hover is dead under a thumb), and the reload command rides the CONTEXT
* (`useWebPreview().reload()`) because the web's consumer-wired iframe ref has no
* RN counterpart — the context exposes exactly what the verdict names.
*
* - THE CONSOLE PANE IS DROPPED, per the verdict: no WebPreviewConsole part and no
* consoleOpen in the context state.
*
* SIZING: the root is flex-1 inside a column — give the host a bounded height (the
* web's `size-full` needs a sized parent here too). The webview itself takes NO
* className: it is a third-party codegen component, and routing className into it
* would need engine-specific cssInterop, which the styling contract forbids in
* registry source. It fills its wrapper through numeric width/height — the same
* belt-and-braces slot sources.tsx's favicon Image uses.
*/
type WebPreviewContextValue = {
/** The committed URL — normalized, or '' when nothing is loaded. */
url: string;
/** Commit a URL: upstream's setUrl name, firing onUrlChange exactly as upstream does. */
setUrl: (url: string) => void;
/** The verdict's reload affordance: the webview's reload command (no-op unmounted). */
reload: () => void;
/** History back — the webview's goBack (no-op unmounted, same law). */
goBack: () => void;
/** History forward — the webview's goForward (no-op unmounted, same law). */
goForward: () => void;
/** The body registers its webview instance here so reload reaches it. */
attachWebview: (instance: WebView | null) => void;
};
const WebPreviewContext = React.createContext<WebPreviewContextValue | null>(null);
function useWebPreview() {
const ctx = React.useContext(WebPreviewContext);
// Upstream trap, message byte-verbatim.
if (!ctx) {
throw new Error('WebPreview components must be used within a WebPreview');
}
return ctx;
}
export type WebPreviewProps = ViewProps & {
/** The initial URL. Committed as-is; the body normalizes before navigating. */
defaultUrl?: string;
/** Fires on every COMMIT (Enter in the URL bar), never per keystroke — upstream contract. */
onUrlChange?: (url: string) => void;
children?: React.ReactNode;
};
function WebPreview({ defaultUrl = '', onUrlChange, className, children, ...props }: WebPreviewProps) {
const [url, setUrlState] = React.useState(defaultUrl);
const webviewRef = React.useRef<WebView | null>(null);
const attachWebview = React.useCallback((instance: WebView | null) => {
webviewRef.current = instance;
}, []);
const handleUrlChange = React.useCallback(
(nextUrl: string) => {
setUrlState(nextUrl);
onUrlChange?.(nextUrl);
},
[onUrlChange],
);
const reload = React.useCallback(() => {
// No webview mounted → a no-op, never a pretend reload.
webviewRef.current?.reload();
}, []);
const goBack = React.useCallback(() => {
// No webview mounted → a no-op, never a pretend navigation.
webviewRef.current?.goBack();
}, []);
const goForward = React.useCallback(() => {
webviewRef.current?.goForward();
}, []);
const contextValue = React.useMemo<WebPreviewContextValue>(
() => ({ url, setUrl: handleUrlChange, reload, goBack, goForward, attachWebview }),
[url, handleUrlChange, reload, goBack, goForward, attachWebview],
);
return (
<WebPreviewContext.Provider value={contextValue}>
<View
// overflow-hidden is a native addition: RN does not clip children to a rounded
// border, and an unclipped webview pokes past the corners.
className={cn('flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card', className)}
{...props}
>
{children}
</View>
</WebPreviewContext.Provider>
);
}
type WebPreviewNavigationProps = ViewProps & {
children?: React.ReactNode;
};
/** The control row — upstream's `flex items-center gap-1 border-b p-2`, flex-row explicit. */
function WebPreviewNavigation({ className, children, ...props }: WebPreviewNavigationProps) {
return (
<View
className={cn('flex-row items-center gap-1 border-b border-border p-2', className)}
{...props}
>
{children}
</View>
);
}
type WebPreviewNavigationButtonProps = {
onPress?: () => void;
disabled?: boolean;
/**
* The web tooltip's replacement (hover is dead under a thumb): the accessibility
* label. Required — an icon-only button without one is a silent target.
*/
label: string;
children?: React.ReactNode;
className?: string;
};
/**
* A ghost icon button, mountable OUTSIDE a WebPreview root (the upstream test corpus
* does exactly that — so this part reads no context).
*/
function WebPreviewNavigationButton({
onPress,
disabled,
label,
children,
className,
}: WebPreviewNavigationButtonProps) {
return (
<Button
variant="ghost"
size="icon"
onPress={onPress}
disabled={disabled}
accessibilityLabel={label}
// House formula: RNR's h-10 (40pt) + hitSlop 2/side = the 44pt platform minimum.
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={className}
>
{children}
</Button>
);
}
type WebPreviewUrlProps = {
/** Fully controls the field's draft, upstream's value passthrough. */
value?: string;
/** Per-keystroke passthrough, upstream's onChange. */
onChangeText?: (text: string) => void;
/** Fires with the NORMALIZED url on Enter, alongside the root's onUrlChange. */
onUrlCommit?: (url: string) => void;
/** A non-empty draft the allowlist refused. Never silent, never navigated. */
onRefuse?: (raw: string) => void;
/** Upstream's placeholder, byte-verbatim by default. */
placeholder?: string;
className?: string;
};
/**
* The URL bar, composed on the registered InputGroup atom exactly as the web composes
* its own Input. Enter commits (the soft keyboard's "go" key); typing does not.
* Derived-state sync: when the context URL changes from outside (the caller's state),
* the draft follows it — the upstream pattern, kept.
*/
function WebPreviewUrl({
value,
onChangeText,
onUrlCommit,
onRefuse,
placeholder = 'Enter URL...',
className,
}: WebPreviewUrlProps) {
const { url, setUrl } = useWebPreview();
const [prevUrl, setPrevUrl] = React.useState(url);
const [draft, setDraft] = React.useState(url);
if (url !== prevUrl) {
setPrevUrl(url);
setDraft(url);
}
function handleCommit() {
const trimmed = draft.trim();
if (!trimmed) {
// The web original commits the empty string (the iframe goes blank); so does the
// port — the body renders its empty placeholder. Clearing is a real commit.
setUrl('');
onUrlCommit?.('');
return;
}
const normalized = normalizePreviewUrl(trimmed);
if (!normalized) {
// Refused (javascript:, data:, file:, garbage). The field keeps the draft so the
// user can see and fix what was wrong; the caller is told, never silently.
onRefuse?.(draft);
return;
}
setUrl(normalized);
onUrlCommit?.(normalized);
}
return (
<InputGroup className={cn('min-w-0 flex-1', className)}>
<InputGroupInput
value={value ?? draft}
onChangeText={(text: string) => {
setDraft(text);
onChangeText?.(text);
}}
onSubmitEditing={handleCommit}
placeholder={placeholder}
// Keyboard-proofing for an address field (snippet.tsx's field rules).
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="go"
accessibilityLabel="Preview URL"
/>
</InputGroup>
);
}
type WebPreviewBodyProps = {
/** Overrides the context URL for this body — upstream's src passthrough. */
src?: string;
/** Shown as an overlay while the webview reports loading. Upstream's `loading` node. */
loading?: React.ReactNode;
className?: string;
};
/**
* The webview itself. The committed URL was already normalized at the bar, but the
* context can be fed from caller state — so the body normalizes AGAIN here. Nothing
* but an allowlisted http(s) URL ever reaches `source` (the security scenario's guard
* is not downgradable). No URL → an empty placeholder, never a navigation.
*/
function WebPreviewBody({ src, loading, className }: WebPreviewBodyProps) {
const { url, reload, attachWebview } = useWebPreview();
const target = React.useMemo(() => normalizePreviewUrl(src ?? url), [src, url]);
const [isLoading, setIsLoading] = React.useState(false);
const [failed, setFailed] = React.useState(false);
return (
<View className={cn('flex-1 bg-background', className)}>
{target ? (
<WebView
ref={attachWebview}
source={{ uri: target }}
// ---- The sandbox mapping. Fixed, not props — see the header. ----
originWhitelist={['https://*', 'http://*']}
// The upstream sandbox's allow-scripts GRANT, pinned explicit so a default
// flip upstream cannot silently change the posture; live pages need it (AC-1).
javaScriptEnabled={true}
allowFileAccess={false}
allowFileAccessFromFileURLs={false}
allowUniversalAccessFromFileURLs={false}
setSupportMultipleWindows={false}
// ----------------------------------------------------------------
// Belt-and-braces: the webview takes no className (see the header) and
// must fill its wrapper exactly.
style={{ width: '100%', height: '100%' }}
onLoadStart={() => {
// A fresh attempt clears the previous failure (Retry lands here too).
setFailed(false);
setIsLoading(true);
}}
onLoadEnd={() => setIsLoading(false)}
onError={() => {
setIsLoading(false);
setFailed(true);
}}
onRenderProcessGone={() => {
setIsLoading(false);
setFailed(true);
}}
/>
) : (
// Upstream: an iframe with no src. A webview with no source is a blank white
// view — the exact thing AC-3 forbids mistaking for a result — so the port
// renders an explicit empty surface instead of mounting one.
<View className="flex-1" />
)}
{target && isLoading && loading ? (
<View className="absolute inset-0 items-center justify-center">{loading}</View>
) : null}
{failed && target ? <FailurePanel url={target} onRetry={reload} /> : null}
</View>
);
}
/**
* AC-3's explicit error state. Covers the webview (which may hold a half-rendered or
* native error surface) with the background, names the failing host (lib/url's
* no-throw extractor), and offers Retry — the context reload.
*/
function FailurePanel({ url, onRetry }: { url: string; onRetry: () => void }) {
return (
<View className="absolute inset-0 flex-col items-center justify-center gap-2 bg-background p-6">
<Icon as={TriangleAlertIcon} size={24} className="text-destructive" />
<Text className="text-center text-sm font-medium text-foreground">
Preview failed to load
</Text>
<Text numberOfLines={1} className="text-center text-xs text-muted-foreground">
{hostnameOf(url)}
</Text>
<Button
variant="outline"
size="sm"
onPress={onRetry}
accessibilityLabel="Retry loading the preview"
>
<Text>Retry</Text>
</Button>
</View>
);
}
export {
WebPreview,
WebPreviewBody,
WebPreviewNavigation,
WebPreviewNavigationButton,
WebPreviewUrl,
useWebPreview,
};
/**
* WebPreview — pure logic. Zero react-native imports, so the Vitest tier owns it (see
* the header of lib/reasoning-lifecycle.ts for why component files themselves cannot
* load under Node).
*
* The URL commit pipeline. The web original commits the raw input value straight into
* the iframe's src — the BROWSER is the guard there (a scheme-less draft resolves
* against the page origin, and `javascript:` in a src assignment is dead). A native
* webview has no such chaperone: whatever reaches `source={{uri}}` is navigated, so
* the guard has to live here, BEFORE the webview ever sees the string.
*
* NO-THROW IS THE CONTRACT (lib/url's rule). A malformed draft is refused, never a
* crash — and refusal returns '' rather than a best-effort guess, because an empty
* result renders the body's empty placeholder while a wrong guess would NAVIGATE.
*/
import { isSafeOpenUrl } from '@/registry/nativewind/lib/url';
/**
* Schemes that are recognized AS schemes. Everything else the user typed —
* `example.com:8443`, `myhost:3000` — is host:port, not a scheme, so it takes the
* https-default branch instead of being refused for carrying an unknown protocol.
* The known list is what a hostile draft can actually ride: javascript:/data:/file:
* are recognized precisely so the allowlist gate can refuse them below.
*/
const KNOWN_SCHEME = /^(https?|file|javascript|data|blob|about|ftp|ws|wss):/i;
/**
* Dev-preview hosts. `localhost:3000` looks like a scheme but is host:port, and it is
* the ONE host a plain-https prefix would break — local previews are http. The
* loopback forms gain `http://`, everything else gains `https://`.
*/
const LOCAL_HOST = /^(localhost|127\.0\.0\.1)(:\d+)?([/?#].*)?$/i;
/**
* The commit gate: a draft becomes a navigable URL or ''. Rules, in order:
* 1. empty/whitespace → '' (a real commit of the empty string — the web original
* commits it too, and the body renders its empty placeholder);
* 2. loopback host[:port][:path] → `http://…`;
* 3. scheme-less input → `https://…` (the browser address-bar contract);
* 4. anything with a known scheme stands as typed;
* 5. FINAL GATE — lib/url's http/https allowlist. `javascript:`, `data:`,
* `file:`, every other scheme is refused ('') no matter how it got here.
*
* Every branch funnels through one canonicalization, so the result is always the URL
* standard's `href` (scheme and host lowercased, path normalized) and the URL bar
* shows the form that will actually load. A draft that cannot parse at all — and any
* unknown custom scheme, which the https-default turns into an inert navigation to a
* nonexistent host — is the failure panel's job, never a crash.
*/
export function normalizePreviewUrl(raw: string | null | undefined): string {
if (!raw) return '';
const trimmed = raw.trim();
if (!trimmed) return '';
const candidate = LOCAL_HOST.test(trimmed)
? `http://${trimmed}`
: KNOWN_SCHEME.test(trimmed)
? trimmed
: `https://${trimmed}`;
if (!isSafeOpenUrl(candidate)) return '';
try {
return new URL(candidate).href;
} catch {
return '';
}
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| defaultUrl | string | undefined | '' | The initial URL. Committed as-is; the body normalizes before navigating. |
| onUrlChange | ((url: string) => void) | undefined | — | Fires on every COMMIT (Enter in the URL bar), never per keystroke — upstream contract. |
| children | React.ReactNode | — | — |
Theming
Themed via border-border, bg-card, bg-background, text-destructive, text-foreground, text-muted-foreground.
Usage
() => (
<WebPreview defaultUrl="https://reactnativereusables.com">
<WebPreviewNavigation>
<WebPreviewUrl />
</WebPreviewNavigation>
<WebPreviewBody />
</WebPreview>
)