Skip to content

Commit

Permalink
refactor: makeWebshell now takes feature instances
Browse files Browse the repository at this point in the history
BREAKING CHANGE: makeWebshell now requires to provide a list of Feature
instances instead of the result of feature `assemble` members.
  • Loading branch information
jsamr committed Sep 25, 2020
1 parent d028853 commit ed28385
Showing 1 changed file with 217 additions and 0 deletions.
217 changes: 217 additions & 0 deletions packages/core/src/make-webshell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/* eslint-disable dot-notation */
import * as React from 'react';
import type {
ComponentType,
ElementRef,
ComponentProps,
ComponentPropsWithRef
} from 'react';
import type { NativeSyntheticEvent } from 'react-native';
import { Feature } from './Feature';
import featuresLoaderScript from './features-loader.webjs';
import type {
WebshellProps,
WebshellInvariantProps,
MinimalWebViewProps,
PropsSpecs,
PropDefinition
} from './types';

interface WebViewMessage {
data: string;
}

interface PostMessage {
identifier: string;
handlerName: string;
type: 'feature' | 'error' | 'log';
severity: 'warn' | 'info';
body: any;
}

function parseJSONSafe(text: string) {
try {
return (JSON.parse(text) as unknown) ?? null;
} catch (e) {
return null;
}
}

function isPostMessageObject(o: unknown): o is PostMessage {
return (
typeof o === 'object' &&
o !== null &&
typeof o['identifier'] === 'string' &&
typeof o['type'] === 'string' &&
o['__isWebshellPostMessage'] === true
);
}

function serializeFeature(feature: Feature<any, PropsSpecs<any>>) {
const propDef = feature.propSpecs.find((f) => f.type === 'handler');
return `{
source:${feature.script},
identifier:${JSON.stringify(feature.featureIdentifier)},
options:${JSON.stringify(feature.options || {})},
handlerName: ${JSON.stringify(propDef?.name || '')}
}`;
}

function serializeFeatureList(feats: Feature<any, any>[]) {
return `[${feats.map(serializeFeature).join(',')}]`;
}

function extractFeatureProps(
props: WebshellProps<any, any>,
propsMap: Record<string, PropDefinition<any>>,
type: 'handler' | 'inert' | null = null
): any {
return Object.keys(props).reduce((obj, key) => {
if (propsMap[key] && (type == null || propsMap[key].type === type)) {
return {
...obj,
[key]: props[key]
};
}
return obj;
}, {});
}

function filterWebViewProps<W>(
props: WebshellProps<any, any>,
propsMap: Record<string, PropDefinition<any>>
): W {
return Object.keys(props).reduce((obj, key) => {
if (propsMap[key] || key.startsWith('webshell')) {
return obj;
}
return {
...obj,
[key]: props[key]
};
}, {} as W);
}

function extractPropsSpecsMap(features: Feature<any, PropsSpecs<any>>[]) {
return features
.map((f: Feature<any, PropsSpecs<any>>) => f.propSpecs)
.reduce((p, c) => [...p, ...c], [])
.reduce(
(map, spec: PropDefinition<any>) => ({ ...map, [spec.name]: spec }),
{}
) as Record<string, PropDefinition<any>>;
}

export function assembleScript(serializedFeatureList: string) {
return featuresLoaderScript
.replace('$$___FEATURES___$$', serializedFeatureList)
.replace('$$__DEBUG__$$', `${__DEV__}`);
}

/**
* Creates a React component which decorates WebView component with additional
* props to handle events from the DOM.
*
* @param WebView - A WebView component, typically exported from `react-native-webview`.
* @param features - Features ready to be loaded in the WebView.
*
* @public
*/
export function makeWebshell<
C extends ComponentType<any>,
F extends Feature<any, any>[]
>(
WebView: C,
...features: F
): React.ForwardRefExoticComponent<
WebshellProps<React.ComponentPropsWithoutRef<C>, F> &
React.RefAttributes<ElementRef<C>>
> {
const filteredFeatures = features.filter((f) => !!f);
const propsMap = extractPropsSpecsMap(filteredFeatures);
const serializedFeatureScripts = serializeFeatureList(filteredFeatures);
const injectableScript = assembleScript(serializedFeatureScripts);
const Webshell = (
props: WebshellProps<ComponentProps<C>, F> & { webViewRef: ElementRef<C> }
) => {
const {
onMessage,
onDOMError,
webshellDebug,
...otherProps
} = props as WebshellInvariantProps & MinimalWebViewProps;
const domHandlers = extractFeatureProps(otherProps, propsMap, 'handler');
const handleOnMessage = React.useCallback(
({ nativeEvent }: NativeSyntheticEvent<WebViewMessage>) => {
const parsedJSON = parseJSONSafe(nativeEvent.data);
if (isPostMessageObject(parsedJSON)) {
const { type, identifier, body, handlerName, severity } = parsedJSON;
if (type === 'feature') {
const handler =
typeof handlerName === 'string' ? domHandlers[handlerName] : null;
if (propsMap[handlerName]) {
if (typeof handler === 'function') {
handler(body);
} else {
webshellDebug &&
console.info(
`[Webshell]: script from feature "${identifier}" sent an event, but there ` +
`is no handler prop named "${handlerName}" attached to the shell.`
);
}
} else {
console.warn(
`[Webshell]: script from feature "${identifier}" sent an event, but there is ` +
`no handler named "${handlerName}" defined for this feature. ` +
'Use FeatureBuilder.withEventHandlerProp to register that handler, or make ' +
'sure its name is not misspell in the DOM script.'
);
}
} else if (type === 'error') {
// Handle as an error message
typeof onDOMError === 'function' && onDOMError(identifier, body);
webshellDebug &&
console.warn(
`[Webshell]: script from feature "${identifier}" raised an error: ${body}`
);
return;
} else if (type === 'log') {
webshellDebug && severity === 'warn' && console.warn(body);
webshellDebug && severity === 'info' && console.info(body);
}
} else {
typeof onMessage === 'function' && onMessage(nativeEvent);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[...Object.values(domHandlers), onDOMError, onMessage]
);
const { webViewRef, injectedJavaScript, ...webViewProps } = props;
const resultingJavascript = React.useMemo(() => {
const safeUserscript =
typeof injectedJavaScript === 'string' ? injectedJavaScript : '';
return `(function(){${safeUserscript};${injectableScript};})();true;`;
}, [injectedJavaScript]);
return (
<WebView
{...filterWebViewProps(webViewProps, propsMap)}
ref={webViewRef}
injectedJavaScript={resultingJavascript}
javaScriptEnabled={true}
onMessage={handleOnMessage}
/>
);
};
Webshell.defaultProps = {
webshellDebug: __DEV__
};
return React.forwardRef<
ElementRef<C>,
WebshellProps<ComponentPropsWithRef<C>, F>
>((props, ref) => (
<Webshell
webViewRef={ref}
{...(props as WebshellProps<ComponentPropsWithRef<any>, F>)}
/>
)) as any;
}

0 comments on commit ed28385

Please sign in to comment.