chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
+116
View File
@@ -0,0 +1,116 @@
jest.mock('react-native/Libraries/Core/Devtools/getDevServer', () => ({
__esModule: true,
default: jest.fn().mockReturnValue({ url: 'http://localhost:8081' }),
}));
describe('getBaseURL', () => {
let getBaseURL: typeof import('../base').getBaseURL;
const originalProcessEnv = process.env;
const originalExpo = globalThis.expo;
beforeEach(() => {
// Reset the module to clear the `getBaseURL` underlying cache
jest.resetModules();
getBaseURL = require('../base').getBaseURL;
});
afterEach(() => {
globalThis.expo = originalExpo;
process.env = originalProcessEnv;
});
it('should serve from updates directory when using updates', () => {
// @ts-expect-error: mock partial properties
globalThis.expo = {
modules: {
ExpoUpdates: {
isEnabled: true,
isEmbeddedLaunch: false,
localAssets: {
'8d4e297c3b3e49a614248143d53e40ca':
'file:///android_res/drawable-mdpi/node_modules_reactnavigation_elements_lib_module_assets_closeicon.png',
'4403c6117ec30c859bc95d70ce4a71d3':
'file:///android_res/drawable-mdpi/node_modules_reactnavigation_elements_lib_module_assets_searchicon.png',
'5d41402abc4b2a76b9719d911017c592':
'file:///path/to/.expo-internal/5d41402abc4b2a76b9719d911017c592.png',
'1d1ea1496f9057eb392d5bbf3732a61b7':
'file:///android_res/drawable/node_modules_exporouter_assets_error.png',
},
},
},
};
expect(getBaseURL()).toBe('file:///path/to/.expo-internal');
});
it('should serve from app builtin directory when using updates with embedded bundle', () => {
// @ts-expect-error: mock partial properties
globalThis.expo = {
modules: {
ExpoUpdates: {
isEnabled: true,
isEmbeddedLaunch: true,
localAssets: {
'8d4e297c3b3e49a614248143d53e40ca':
'file:///android_res/drawable-mdpi/node_modules_reactnavigation_elements_lib_module_assets_closeicon.png',
'4403c6117ec30c859bc95d70ce4a71d3':
'file:///android_res/drawable-mdpi/node_modules_reactnavigation_elements_lib_module_assets_searchicon.png',
'5d41402abc4b2a76b9719d911017c592':
'file:///path/to/.expo-internal/5d41402abc4b2a76b9719d911017c592.png',
'1d1ea1496f9057eb392d5bbf3732a61b7':
'file:///android_res/drawable/node_modules_exporouter_assets_error.png',
},
},
},
};
process.env.NODE_ENV = 'production';
switch (process.env.EXPO_OS) {
case 'android': {
expect(getBaseURL()).toBe('file:///android_asset/www.bundle');
break;
}
case 'ios': {
expect(getBaseURL()).toBe('www.bundle');
break;
}
default: {
expect(getBaseURL()).toBe('');
break;
}
}
});
it('should serve from app builtin directory for production builds', () => {
process.env.NODE_ENV = 'production';
switch (process.env.EXPO_OS) {
case 'android': {
expect(getBaseURL()).toBe('file:///android_asset/www.bundle');
break;
}
case 'ios': {
expect(getBaseURL()).toBe('www.bundle');
break;
}
default: {
expect(getBaseURL()).toBe('');
break;
}
}
});
it('should serve from dev server for development builds', () => {
process.env.NODE_ENV = 'development';
switch (process.env.EXPO_OS) {
case 'android':
case 'ios': {
expect(getBaseURL()).toBe('http://localhost:8081/_expo/@dom');
break;
}
default: {
expect(getBaseURL()).toBe('');
break;
}
}
});
});
@@ -0,0 +1,35 @@
describe('resolveWebView', () => {
beforeEach(() => {
jest.resetModules();
});
it('should resolve @expo/dom-webview when useExpoDOMWebView is true', async () => {
jest.doMock('@expo/dom-webview', () => ({ WebView: 'StubExpoDOMWebView' }));
const { resolveWebView } = await import('../webview-wrapper');
const webView = resolveWebView(true);
expect(webView).toBe('StubExpoDOMWebView');
});
it('should resolve react-native-webview when useExpoDOMWebView is false', async () => {
jest.doMock('react-native-webview', () => ({ WebView: 'StubRNWebView' }));
const { resolveWebView } = await import('../webview-wrapper');
const webView = resolveWebView(false);
expect(webView).toBe('StubRNWebView');
});
it('should throw an error if @expo/dom-webview cannot be resolved when useExpoDOMWebView is true', async () => {
jest.doMock('@expo/dom-webview', () => null);
const { resolveWebView } = await import('../webview-wrapper');
expect(() => resolveWebView(true)).toThrow(
"Unable to resolve the '@expo/dom-webview' module. Make sure to install it with 'npx expo install @expo/dom-webview'."
);
});
it('should throw an error if react-native-webview cannot be resolved when useExpoDOMWebView is false', async () => {
jest.doMock('react-native-webview', () => null);
const { resolveWebView } = await import('../webview-wrapper');
expect(() => resolveWebView(false)).toThrow(
"Unable to resolve the 'react-native-webview' module. Make sure to install it with 'npx expo install react-native-webview'."
);
});
});
+65
View File
@@ -0,0 +1,65 @@
let cachedBaseUrl: string | null = null;
/**
* Get the base URL for the DOM Components HTML
*/
export function getBaseURL(): string {
if (cachedBaseUrl != null) {
return cachedBaseUrl;
}
// Serving from updates
const updatesBaseUrl = getUpdatesBaseURL();
if (updatesBaseUrl != null) {
cachedBaseUrl = updatesBaseUrl;
return cachedBaseUrl;
}
if (process.env.EXPO_OS === 'web') {
cachedBaseUrl = process.env.EXPO_BASE_URL ?? '';
return cachedBaseUrl;
}
// Serving from local production
if (process.env.NODE_ENV === 'production') {
if (process.env.EXPO_OS === 'android') {
cachedBaseUrl = 'file:///android_asset/www.bundle';
} else if (process.env.EXPO_OS === 'ios') {
cachedBaseUrl = 'www.bundle';
} else {
cachedBaseUrl = process.env.EXPO_BASE_URL ?? '';
}
return cachedBaseUrl;
}
// Serving from local dev server
const getDevServer = require('react-native/Libraries/Core/Devtools/getDevServer').default;
const devServer = getDevServer();
cachedBaseUrl = new URL('/_expo/@dom', devServer.url).toString();
return cachedBaseUrl;
}
/**
* Get the base URL for the DOM Components when serving from updates
*/
function getUpdatesBaseURL(): string | null {
const ExpoUpdates = globalThis.expo?.modules?.['ExpoUpdates'] as
| import('expo-updates').ExpoUpdatesModule
| undefined;
const updatesIsInstalledAndEnabled = ExpoUpdates?.isEnabled ?? false;
const updatesIsEmbeddedLaunch = ExpoUpdates?.isEmbeddedLaunch ?? false;
const shouldServeDomFromUpdates = updatesIsInstalledAndEnabled && !updatesIsEmbeddedLaunch;
// If updates is installed and enabled, and we're not running from an embedded launch, we should serve the DOM Components from the `.expo-internal` directory
if (shouldServeDomFromUpdates) {
const localAssets = ExpoUpdates?.localAssets ?? {};
const anyLocalAsset = Object.values(localAssets).find(
(asset) =>
!asset.startsWith('file:///android_res/') && !asset.startsWith('file:///android_asset/')
);
if (anyLocalAsset) {
// Try to get the `.expo-internal` directory from the first local asset
return anyLocalAsset.slice(0, anyLocalAsset.lastIndexOf('/'));
}
}
return null;
}
+128
View File
@@ -0,0 +1,128 @@
// Entry file for a DOM Component.
import '@expo/metro-runtime';
import { withErrorOverlay } from '@expo/metro-runtime/error-overlay';
import React from 'react';
import { JSONValue } from './dom.types';
import { addEventListener, getActionsObject } from './marshal';
import registerRootComponent from '../launch/registerRootComponent';
interface MarshalledProps {
name: string[];
props: Record<string, JSONValue>;
[key: string]: undefined | JSONValue;
}
interface WindowType {
$$EXPO_INITIAL_PROPS?: MarshalledProps;
}
declare let window: WindowType;
const ACTIONS = getActionsObject!();
function isBaseObject(obj: any) {
if (Object.prototype.toString.call(obj) !== '[object Object]') {
return false;
}
const proto = Object.getPrototypeOf(obj);
if (proto === null) {
return true;
}
return proto === Object.prototype;
}
function isErrorShaped(error: any): error is Error {
return (
error &&
typeof error === 'object' &&
typeof error.name === 'string' &&
typeof error.message === 'string'
);
}
/**
* After we throw this error, any number of tools could handle it.
* This check ensures the error is always in a reasonable state before surfacing it to the runtime.
*/
function convertError(error: any) {
if (isErrorShaped(error)) {
return error;
}
if (process.env.NODE_ENV === 'development') {
if (error == null) {
return new Error('A null/undefined error was thrown.');
}
}
if (isBaseObject(error)) {
return new Error(JSON.stringify(error));
}
return new Error(String(error));
}
export function registerDOMComponent(AppModule: any) {
function DOMComponentRoot(props: Record<string, unknown>) {
// Props listeners
const [marshalledProps, setProps] = React.useState(() => {
if (typeof window.$$EXPO_INITIAL_PROPS === 'undefined') {
throw new Error(
'Initial props are not defined. This is a bug in the DOM Component runtime.'
);
}
return window.$$EXPO_INITIAL_PROPS;
});
React.useEffect(() => {
const remove = addEventListener!((msg) => {
if (msg.type === '$$props') {
setProps(msg.data as MarshalledProps);
}
});
return () => {
remove();
};
}, [setProps]);
const proxyActions = React.useMemo(() => {
if (!marshalledProps.names) return {};
// Create a named map { [name: string]: ProxyFunction }
// TODO(@kitten): Unclear how this is typed or shaped
return Object.fromEntries(
(marshalledProps.names as string[]).map((key: string) => {
return [key, ACTIONS[key]];
})
);
}, [marshalledProps.names]);
return <AppModule {...props} {...(marshalledProps.props || {})} {...proxyActions} />;
}
try {
React.startTransition(() => {
if (process.env.NODE_ENV !== 'production') {
registerRootComponent(withErrorOverlay(DOMComponentRoot));
} else {
registerRootComponent(DOMComponentRoot);
}
});
} catch (e) {
const error = convertError(e);
// Prevent the app from throwing confusing:
// ERROR Invariant Violation: "main" has not been registered. This can happen if:
// * Metro (the local dev server) is run from the wrong folder. Check if Metro is running, stop it and restart it in the current project.
// * A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called.
registerRootComponent(() => React.createElement('div'));
console.error(error);
console.error(`A runtime error has occurred while rendering the root component.`);
// Give React a tick to render before throwing.
setTimeout(() => {
throw error;
});
}
}
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useImperativeHandle, type DependencyList, type Ref } from 'react';
import type { DOMImperativeFactory } from './dom.types';
import { REGISTER_DOM_IMPERATIVE_HANDLE_PROPS } from './injection';
declare namespace globalThis {
let _domRefProxy: undefined | unknown;
}
/**
* A React `useImperativeHandle` like hook for DOM components.
*
*/
export function useDOMImperativeHandle<T extends DOMImperativeFactory>(
ref: Ref<T>,
init: () => T,
deps?: DependencyList
) {
const isTargetWeb =
// @ts-expect-error: Added via react-native-webview
typeof window.ReactNativeWebView === 'undefined' &&
// @ts-expect-error: Added via expo/dom
typeof window.$$EXPO_INITIAL_PROPS === 'undefined';
const stubHandlerFactory = useCallback(() => ({}) as T, deps ?? []);
// This standard useImperativeHandle hook is serving for web
useImperativeHandle(ref, isTargetWeb ? init : stubHandlerFactory, deps);
// This `globalThis._domRefProxy` is serving for native
useEffect(() => {
if (!isTargetWeb) {
globalThis._domRefProxy = init();
// TODO(@kitten): Type `ReactNativeWebView` and the message data
// @ts-expect-error: Added via react-native-webview
window.ReactNativeWebView.postMessage(
JSON.stringify({
type: REGISTER_DOM_IMPERATIVE_HANDLE_PROPS,
data: Object.keys(globalThis._domRefProxy as any),
})
);
}
return () => {
if (!isTargetWeb) {
globalThis._domRefProxy = undefined;
}
};
}, deps);
}
+8
View File
@@ -0,0 +1,8 @@
// Native file
export * from './dom-hooks';
export type { DOMProps, DOMImperativeFactory } from './dom.types';
// TODO: Maybe this could be a bundler global instead.
/** @returns `true` when the current JS running in a DOM Component environment. */
export const IS_DOM = false;
+39
View File
@@ -0,0 +1,39 @@
import type { RNWebView, RNWebViewProps } from './webview/RNWebView';
export type JSONValue = boolean | number | string | null | JSONArray | JSONObject;
export interface JSONArray extends Array<JSONValue> {}
export interface JSONObject {
[key: string]: JSONValue | undefined;
}
export type BridgeMessage<TData extends JSONValue> = {
type: string;
data: TData;
};
/**
* The return type of the init function for `useDOMImperativeHandle`.
*/
export interface DOMImperativeFactory {
[key: string]: (...args: JSONValue[]) => void;
}
type RNWebViewRef = RNWebView;
export type WebViewRef = RNWebViewRef;
export type WebViewProps = RNWebViewProps;
export interface DOMProps extends Omit<RNWebViewProps, 'source'> {
/**
* Whether to resize the native WebView size based on the DOM content size.
* @default false
*/
matchContents?: boolean;
/**
* Whether to use the `@expo/dom-webview` as the underlying WebView implementation.
* @default false
*/
useExpoDOMWebView?: boolean;
}
+6
View File
@@ -0,0 +1,6 @@
export * from './dom-hooks';
// TODO: Maybe this could be a bundler global instead.
export const IS_DOM =
// @ts-expect-error: Added via react-native-webview
typeof $$EXPO_INITIAL_PROPS !== 'undefined';
+16
View File
@@ -0,0 +1,16 @@
import { BridgeMessage, JSONValue } from './dom.types';
const globalListeners = new Set<(message: BridgeMessage<any>) => void>();
export function _emitGlobalEvent<TData extends JSONValue>(message: BridgeMessage<TData>) {
globalListeners.forEach((listener) => listener(message));
}
export const addGlobalDomEventListener = <TData extends JSONValue>(
onSubscribe: (message: BridgeMessage<TData>) => void
): (() => void) => {
globalListeners.add(onSubscribe);
return () => {
globalListeners.delete(onSubscribe);
};
};
+42
View File
@@ -0,0 +1,42 @@
import type { BridgeMessage } from './dom.types';
export const NATIVE_ACTION = '$$native_action';
export const NATIVE_ACTION_RESULT = '$$native_action_result';
export const DOM_EVENT = '$$dom_event';
export const MATCH_CONTENTS_EVENT = '$$match_contents_event';
export const REGISTER_DOM_IMPERATIVE_HANDLE_PROPS = '$$register_dom_imperative_handle_props';
export const getInjectEventScript = <T extends BridgeMessage<any>>(detail: T) => {
return `;(function() {
try {
window.dispatchEvent(new CustomEvent("${DOM_EVENT}",${JSON.stringify({ detail })}));
} catch (e) {}
})();
true;`;
};
export function getInjectBodySizeObserverScript() {
return `;(function observeDocumentBodySize() {
window.addEventListener('DOMContentLoaded', () => {
new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
window.ReactNativeWebView?.postMessage(JSON.stringify({
type: '${MATCH_CONTENTS_EVENT}',
data: {
width,
height,
},
}));
})
.observe(document.body);
window.ReactNativeWebView?.postMessage(JSON.stringify({
type: '${MATCH_CONTENTS_EVENT}',
data: {
width: document.body.clientWidth,
height: document.body.clientHeight,
},
}));
});
})();
true;`;
}
+5
View File
@@ -0,0 +1,5 @@
export { default as WebView } from './webview-wrapper';
// Skip all dom-only functions to give 'undefined is not a function' errors.
export const registerDOMComponent: undefined | typeof import('./dom-entry').registerDOMComponent =
undefined;
+1
View File
@@ -0,0 +1 @@
export { registerDOMComponent } from './dom-entry';
+118
View File
@@ -0,0 +1,118 @@
import { BridgeMessage, JSONValue } from './dom.types';
import { DOM_EVENT, NATIVE_ACTION, NATIVE_ACTION_RESULT } from './injection';
const IS_DOM =
typeof window !== 'undefined' &&
// @ts-expect-error: Added via expo/dom
typeof window.$$EXPO_INITIAL_PROPS !== 'undefined' &&
// @ts-expect-error: Added via react-native-webview
typeof window.ReactNativeWebView !== 'undefined';
const emit = <TData extends JSONValue>(message: BridgeMessage<TData>) => {
if (!IS_DOM) {
return;
}
(window as any).ReactNativeWebView.postMessage(JSON.stringify(message));
};
export const addEventListener = <TData extends JSONValue>(
onSubscribe: (message: BridgeMessage<TData>) => void
): (() => void) => {
if (!IS_DOM) {
return () => {};
}
const listener = ({ detail }: any) => {
onSubscribe(detail);
};
// TODO: Add component ID to the event name to prevent conflicts with other components.
window.addEventListener(DOM_EVENT, listener);
return () => {
window.removeEventListener(DOM_EVENT, listener);
};
};
function invokeNativeAction(actionId: string, args: any[]): Promise<any> {
if (!IS_DOM) {
throw new Error('Cannot invoke native actions outside of a webview');
}
return new Promise((res, rej) => {
const uid = Math.random().toString(36).slice(2);
const sub = addEventListener<{
uid: string;
actionId: string;
result?: any;
error?: any;
}>((message) => {
if (
message.type === NATIVE_ACTION_RESULT &&
message.data.uid === uid &&
message.data.actionId === actionId
) {
// Unsubscribe from the event listener
sub();
if ('error' in message.data) {
rej(errorFromJson(message.data.error));
}
res(message.data.result);
}
});
emit({
type: NATIVE_ACTION,
data: {
uid,
actionId,
args,
},
});
});
}
export function getActionsObject(): Record<string, (...args: any[]) => void | Promise<any>> {
return new Proxy(
{},
{
get(_target, prop) {
return async (...args: any[]) => {
const resolvedProps = await Promise.all(
args.map((arg, index) => {
if (arg instanceof Promise) {
console.warn(
`The promise passed to native action "${prop.toString()}(${new Array(index).fill(',').join('')}promise)" will be evaluated on the web-side before sending to native. This may not be what you want.`
);
}
return arg;
})
);
// Assert that props must be serializable
resolvedProps.forEach((arg, index) => {
if (!arg) return;
if (typeof arg === 'function') {
console.error('Functions are not supported in arguments');
throw new Error('Functions are not supported in arguments');
} else if (typeof arg === 'object') {
try {
JSON.stringify(arg);
} catch (cause) {
console.error('Functions are not supported in arguments');
throw new Error(`Argument at index ${index} is not serializable`, { cause });
}
}
});
return invokeNativeAction(prop.toString(), resolvedProps);
};
},
}
);
}
function errorFromJson(errorJson: any) {
const error = new Error(errorJson.message);
for (const key of Object.keys(errorJson)) {
(error as any)[key] = errorJson[key];
}
return error;
}
+253
View File
@@ -0,0 +1,253 @@
// A webview without babel to test faster.
import React from 'react';
import { AppState } from 'react-native';
import { getBaseURL } from './base';
import type { BridgeMessage, DOMProps, WebViewProps, WebViewRef } from './dom.types';
import { _emitGlobalEvent } from './global-events';
import {
getInjectBodySizeObserverScript,
getInjectEventScript,
MATCH_CONTENTS_EVENT,
NATIVE_ACTION,
NATIVE_ACTION_RESULT,
REGISTER_DOM_IMPERATIVE_HANDLE_PROPS,
} from './injection';
import ExpoDomWebView from './webview/ExpoDOMWebView';
import RNWebView from './webview/RNWebView';
import { useDebugZeroHeight } from './webview/useDebugZeroHeight';
type RawWebViewProps = React.ComponentProps<Exclude<typeof ExpoDomWebView, undefined>> &
React.ComponentProps<Exclude<typeof RNWebView, undefined>>;
interface Props {
children?: any;
dom?: DOMProps;
filePath: string;
ref: React.Ref<object>;
[propName: string]: unknown;
}
const RawWebView = React.forwardRef<object, Props>((props, ref) => {
const { children, dom, filePath, ref: _ref, ...marshalProps } = props as Props;
if (__DEV__) {
if (children !== undefined) {
throw new Error(
`DOM components do not accept children. Found: ${children} | in component: ${filePath.split('?')[0]}`
);
}
}
if (ref != null && typeof ref === 'object' && ref.current == null) {
ref.current = new Proxy(
{},
{
get(_, prop) {
const propName = String(prop) as keyof WebViewRef;
if (domImperativeHandlePropsRef.current?.includes(propName)) {
return function (...args: any[]) {
const serializedArgs = args.map((arg) => JSON.stringify(arg)).join(',');
webviewRef.current?.injectJavaScript(
`window._domRefProxy.${propName}(${serializedArgs})`
);
};
}
if (typeof webviewRef.current?.[propName] === 'function') {
return function (...args: any[]) {
return (webviewRef.current?.[propName] as any)(...args);
};
}
return undefined;
},
}
);
}
const webView = resolveWebView(dom?.useExpoDOMWebView ?? false);
const webviewRef = React.useRef<WebViewRef>(null);
const domImperativeHandlePropsRef = React.useRef<string[]>([]);
const source = { uri: `${getBaseURL()}/${filePath}` };
const [containerStyle, setContainerStyle] = React.useState<WebViewProps['containerStyle']>(null);
const { debugZeroHeightStyle, debugOnLayout } = useDebugZeroHeight(dom);
const emit = React.useCallback(
(detail: BridgeMessage<any>) => {
webviewRef.current?.injectJavaScript(getInjectEventScript(detail));
},
[webviewRef]
);
// serializable props, action names.
const smartActions = Object.entries(marshalProps).reduce<{
props: Record<string, any>;
names: string[];
}>(
(acc, [key, value]) => {
if (value instanceof Function) {
acc.names.push(key);
} else {
// TODO: Recurse and assert that nested functions cannot be used.
acc.props[key] = value;
}
return acc;
},
{ names: [], props: {} }
);
// When the `marshalProps` change, emit them to the webview.
React.useEffect(() => {
emit({ type: '$$props', data: smartActions });
}, [emit, smartActions]);
return React.createElement(webView, {
webviewDebuggingEnabled: __DEV__,
// Make iOS scrolling feel native.
decelerationRate: process.env.EXPO_OS === 'ios' ? 'normal' : undefined,
// This is a better default for integrating with native navigation.
contentInsetAdjustmentBehavior: 'automatic',
// This is the default in ScrollView and upstream native.
automaticallyAdjustsScrollIndicatorInsets: true,
originWhitelist: ['*'],
allowFileAccess: true,
allowFileAccessFromFileURLs: true,
allowingReadAccessToURL: 'file://',
allowsAirPlayForMediaPlayback: true,
allowsFullscreenVideo: true,
onContentProcessDidTerminate: () => {
webviewRef.current?.reload();
},
onRenderProcessGone: () => {
// Simulate iOS `onContentProcessDidTerminate` behavior to reload when the app is in foreground or back to foreground.
if (AppState.currentState === 'active') {
webviewRef.current?.reload();
return;
}
const subscription = AppState.addEventListener('focus', () => {
webviewRef.current?.reload();
subscription.remove();
});
},
...dom,
containerStyle: [containerStyle, debugZeroHeightStyle, dom?.containerStyle],
onLayout: __DEV__ ? debugOnLayout : dom?.onLayout,
injectedJavaScriptBeforeContentLoaded: [
// On first mount, inject `$$EXPO_INITIAL_PROPS` with the initial props.
`window.$$EXPO_INITIAL_PROPS = ${JSON.stringify(smartActions)};true;`,
dom?.matchContents ? getInjectBodySizeObserverScript() : null,
dom?.injectedJavaScriptBeforeContentLoaded,
'true;',
]
.filter(Boolean)
.join('\n'),
// @ts-expect-error: TODO(@kitten): untyped ref for now
ref: webviewRef,
source,
style: [
dom?.style ? { flex: 1, backgroundColor: 'transparent' } : { backgroundColor: 'transparent' },
dom?.style,
],
onMessage: (event) => {
const { type, data } = JSON.parse(event.nativeEvent.data);
if (type === MATCH_CONTENTS_EVENT) {
if (dom?.matchContents) {
setContainerStyle({
width: data.width,
height: data.height,
});
}
return;
}
if (type === REGISTER_DOM_IMPERATIVE_HANDLE_PROPS) {
domImperativeHandlePropsRef.current = data;
return;
}
if (type === NATIVE_ACTION) {
const action = marshalProps[data.actionId];
if (action == null) {
throw new Error(`Native action "${data.actionId}" is not defined.`);
}
if (typeof action !== 'function' || !(action instanceof Function)) {
throw new Error(`Native action "${data.actionId}" is not a function.`);
}
const emitError = (error: any) => {
emit({
type: NATIVE_ACTION_RESULT,
data: {
uid: data.uid,
actionId: data.actionId,
error: serializeError(error),
},
});
};
const emitResolve = (result?: any) => {
// Send async results back to the DOM proxy for return values.
emit({
type: NATIVE_ACTION_RESULT,
data: {
uid: data.uid,
actionId: data.actionId,
result,
},
});
};
try {
const value = action(...data.args);
if (value instanceof Promise) {
return value
.then((result) => {
emitResolve(result);
})
.catch((error) => {
emitError(error);
});
} else {
// Send async results back to the webview proxy for return values.
return emitResolve(value);
}
} catch (error) {
return emitError(error);
}
} else {
// @ts-expect-error: TODO(@kitten): The two types for this event will never match up, but we know they do
dom?.onMessage?.(event);
}
_emitGlobalEvent({ type, data });
},
});
});
if (__DEV__) {
RawWebView.displayName = 'DOM';
}
function serializeError(error: any) {
if (error instanceof Error) {
return {
message: error.message,
stack: error.stack,
// TODO: Other props...
};
}
return error;
}
export function resolveWebView(
useExpoDOMWebView: boolean
): React.ForwardRefExoticComponent<RawWebViewProps> {
const webView = useExpoDOMWebView ? ExpoDomWebView : RNWebView;
if (webView == null) {
const moduleName = useExpoDOMWebView ? '@expo/dom-webview' : 'react-native-webview';
throw new Error(
`Unable to resolve the '${moduleName}' module. Make sure to install it with 'npx expo install ${moduleName}'.`
);
}
return webView as React.ForwardRefExoticComponent<RawWebViewProps>;
}
export default RawWebView;
+10
View File
@@ -0,0 +1,10 @@
/**
* A re-export of `@expo/dom-webview` that supports optional dependency.
*/
let module: undefined | typeof import('@expo/dom-webview').WebView;
try {
module = require('@expo/dom-webview').WebView;
} catch {}
export default module;
+12
View File
@@ -0,0 +1,12 @@
/**
* A re-export of `react-native-webview` that supports optional dependency.
*/
let module: undefined | typeof import('react-native-webview').WebView;
try {
module = require('react-native-webview').WebView;
} catch {}
export default module;
export type { WebView as RNWebView, WebViewProps as RNWebViewProps } from 'react-native-webview';
+62
View File
@@ -0,0 +1,62 @@
import { useState, useCallback } from 'react';
import { type ViewProps } from 'react-native';
import { type DOMProps, type WebViewProps } from '../dom.types';
type UseDebugZeroHeightType = (dom?: DOMProps) => {
debugZeroHeightStyle: WebViewProps['containerStyle'] | undefined;
debugOnLayout: ViewProps['onLayout'];
};
/**
* Debug only hook to help identify zero height issues in the DOM component.
*/
export const useDebugZeroHeight: UseDebugZeroHeightType = __DEV__
? (dom) => {
const [debugZeroHeightStyle, setDebugZeroHeightStyle] = useState<
WebViewProps['containerStyle'] | undefined
>(undefined);
const [hasLoggedWarning, setHasLoggedWarning] = useState(false);
const debugOnLayout = useCallback<NonNullable<ViewProps['onLayout']>>(
(event) => {
dom?.onLayout?.(event);
if (dom?.matchContents) {
return;
}
if (debugZeroHeightStyle !== undefined) {
return;
}
if (event.nativeEvent.layout.height === 0) {
if (!hasLoggedWarning) {
console.warn(`
The DOM component has a zero height in native hierarchy.
We are adding a debug style to help you identify the issue.
You can remove this style by using the \`matchContents\` prop or explicitly add a height from the component callsite.
\`\`\`
<YourDomComponent dom={{ matchContents: true }} />
// or
<YourDomComponent dom={{ style: { height: 50 } }} />
\`\`\`
`);
setHasLoggedWarning(true);
}
setDebugZeroHeightStyle({
borderWidth: 1,
borderColor: 'red',
borderRadius: 2,
minHeight: 40,
});
} else {
setDebugZeroHeightStyle({});
}
},
[dom?.matchContents, dom?.onLayout, debugZeroHeightStyle, hasLoggedWarning]
);
return { debugZeroHeightStyle, debugOnLayout };
}
: (dom) => ({
debugZeroHeightStyle: undefined,
debugOnLayout: dom?.onLayout,
});