Skip to content

Commit ed28385

Browse files
committed
refactor: makeWebshell now takes feature instances
BREAKING CHANGE: makeWebshell now requires to provide a list of Feature instances instead of the result of feature `assemble` members.
1 parent d028853 commit ed28385

File tree

1 file changed

+217
-0
lines changed

1 file changed

+217
-0
lines changed

packages/core/src/make-webshell.tsx

+217
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/* eslint-disable dot-notation */
2+
import * as React from 'react';
3+
import type {
4+
ComponentType,
5+
ElementRef,
6+
ComponentProps,
7+
ComponentPropsWithRef
8+
} from 'react';
9+
import type { NativeSyntheticEvent } from 'react-native';
10+
import { Feature } from './Feature';
11+
import featuresLoaderScript from './features-loader.webjs';
12+
import type {
13+
WebshellProps,
14+
WebshellInvariantProps,
15+
MinimalWebViewProps,
16+
PropsSpecs,
17+
PropDefinition
18+
} from './types';
19+
20+
interface WebViewMessage {
21+
data: string;
22+
}
23+
24+
interface PostMessage {
25+
identifier: string;
26+
handlerName: string;
27+
type: 'feature' | 'error' | 'log';
28+
severity: 'warn' | 'info';
29+
body: any;
30+
}
31+
32+
function parseJSONSafe(text: string) {
33+
try {
34+
return (JSON.parse(text) as unknown) ?? null;
35+
} catch (e) {
36+
return null;
37+
}
38+
}
39+
40+
function isPostMessageObject(o: unknown): o is PostMessage {
41+
return (
42+
typeof o === 'object' &&
43+
o !== null &&
44+
typeof o['identifier'] === 'string' &&
45+
typeof o['type'] === 'string' &&
46+
o['__isWebshellPostMessage'] === true
47+
);
48+
}
49+
50+
function serializeFeature(feature: Feature<any, PropsSpecs<any>>) {
51+
const propDef = feature.propSpecs.find((f) => f.type === 'handler');
52+
return `{
53+
source:${feature.script},
54+
identifier:${JSON.stringify(feature.featureIdentifier)},
55+
options:${JSON.stringify(feature.options || {})},
56+
handlerName: ${JSON.stringify(propDef?.name || '')}
57+
}`;
58+
}
59+
60+
function serializeFeatureList(feats: Feature<any, any>[]) {
61+
return `[${feats.map(serializeFeature).join(',')}]`;
62+
}
63+
64+
function extractFeatureProps(
65+
props: WebshellProps<any, any>,
66+
propsMap: Record<string, PropDefinition<any>>,
67+
type: 'handler' | 'inert' | null = null
68+
): any {
69+
return Object.keys(props).reduce((obj, key) => {
70+
if (propsMap[key] && (type == null || propsMap[key].type === type)) {
71+
return {
72+
...obj,
73+
[key]: props[key]
74+
};
75+
}
76+
return obj;
77+
}, {});
78+
}
79+
80+
function filterWebViewProps<W>(
81+
props: WebshellProps<any, any>,
82+
propsMap: Record<string, PropDefinition<any>>
83+
): W {
84+
return Object.keys(props).reduce((obj, key) => {
85+
if (propsMap[key] || key.startsWith('webshell')) {
86+
return obj;
87+
}
88+
return {
89+
...obj,
90+
[key]: props[key]
91+
};
92+
}, {} as W);
93+
}
94+
95+
function extractPropsSpecsMap(features: Feature<any, PropsSpecs<any>>[]) {
96+
return features
97+
.map((f: Feature<any, PropsSpecs<any>>) => f.propSpecs)
98+
.reduce((p, c) => [...p, ...c], [])
99+
.reduce(
100+
(map, spec: PropDefinition<any>) => ({ ...map, [spec.name]: spec }),
101+
{}
102+
) as Record<string, PropDefinition<any>>;
103+
}
104+
105+
export function assembleScript(serializedFeatureList: string) {
106+
return featuresLoaderScript
107+
.replace('$$___FEATURES___$$', serializedFeatureList)
108+
.replace('$$__DEBUG__$$', `${__DEV__}`);
109+
}
110+
111+
/**
112+
* Creates a React component which decorates WebView component with additional
113+
* props to handle events from the DOM.
114+
*
115+
* @param WebView - A WebView component, typically exported from `react-native-webview`.
116+
* @param features - Features ready to be loaded in the WebView.
117+
*
118+
* @public
119+
*/
120+
export function makeWebshell<
121+
C extends ComponentType<any>,
122+
F extends Feature<any, any>[]
123+
>(
124+
WebView: C,
125+
...features: F
126+
): React.ForwardRefExoticComponent<
127+
WebshellProps<React.ComponentPropsWithoutRef<C>, F> &
128+
React.RefAttributes<ElementRef<C>>
129+
> {
130+
const filteredFeatures = features.filter((f) => !!f);
131+
const propsMap = extractPropsSpecsMap(filteredFeatures);
132+
const serializedFeatureScripts = serializeFeatureList(filteredFeatures);
133+
const injectableScript = assembleScript(serializedFeatureScripts);
134+
const Webshell = (
135+
props: WebshellProps<ComponentProps<C>, F> & { webViewRef: ElementRef<C> }
136+
) => {
137+
const {
138+
onMessage,
139+
onDOMError,
140+
webshellDebug,
141+
...otherProps
142+
} = props as WebshellInvariantProps & MinimalWebViewProps;
143+
const domHandlers = extractFeatureProps(otherProps, propsMap, 'handler');
144+
const handleOnMessage = React.useCallback(
145+
({ nativeEvent }: NativeSyntheticEvent<WebViewMessage>) => {
146+
const parsedJSON = parseJSONSafe(nativeEvent.data);
147+
if (isPostMessageObject(parsedJSON)) {
148+
const { type, identifier, body, handlerName, severity } = parsedJSON;
149+
if (type === 'feature') {
150+
const handler =
151+
typeof handlerName === 'string' ? domHandlers[handlerName] : null;
152+
if (propsMap[handlerName]) {
153+
if (typeof handler === 'function') {
154+
handler(body);
155+
} else {
156+
webshellDebug &&
157+
console.info(
158+
`[Webshell]: script from feature "${identifier}" sent an event, but there ` +
159+
`is no handler prop named "${handlerName}" attached to the shell.`
160+
);
161+
}
162+
} else {
163+
console.warn(
164+
`[Webshell]: script from feature "${identifier}" sent an event, but there is ` +
165+
`no handler named "${handlerName}" defined for this feature. ` +
166+
'Use FeatureBuilder.withEventHandlerProp to register that handler, or make ' +
167+
'sure its name is not misspell in the DOM script.'
168+
);
169+
}
170+
} else if (type === 'error') {
171+
// Handle as an error message
172+
typeof onDOMError === 'function' && onDOMError(identifier, body);
173+
webshellDebug &&
174+
console.warn(
175+
`[Webshell]: script from feature "${identifier}" raised an error: ${body}`
176+
);
177+
return;
178+
} else if (type === 'log') {
179+
webshellDebug && severity === 'warn' && console.warn(body);
180+
webshellDebug && severity === 'info' && console.info(body);
181+
}
182+
} else {
183+
typeof onMessage === 'function' && onMessage(nativeEvent);
184+
}
185+
},
186+
// eslint-disable-next-line react-hooks/exhaustive-deps
187+
[...Object.values(domHandlers), onDOMError, onMessage]
188+
);
189+
const { webViewRef, injectedJavaScript, ...webViewProps } = props;
190+
const resultingJavascript = React.useMemo(() => {
191+
const safeUserscript =
192+
typeof injectedJavaScript === 'string' ? injectedJavaScript : '';
193+
return `(function(){${safeUserscript};${injectableScript};})();true;`;
194+
}, [injectedJavaScript]);
195+
return (
196+
<WebView
197+
{...filterWebViewProps(webViewProps, propsMap)}
198+
ref={webViewRef}
199+
injectedJavaScript={resultingJavascript}
200+
javaScriptEnabled={true}
201+
onMessage={handleOnMessage}
202+
/>
203+
);
204+
};
205+
Webshell.defaultProps = {
206+
webshellDebug: __DEV__
207+
};
208+
return React.forwardRef<
209+
ElementRef<C>,
210+
WebshellProps<ComponentPropsWithRef<C>, F>
211+
>((props, ref) => (
212+
<Webshell
213+
webViewRef={ref}
214+
{...(props as WebshellProps<ComponentPropsWithRef<any>, F>)}
215+
/>
216+
)) as any;
217+
}

0 commit comments

Comments
 (0)