From a76d3b4daaa39f1bc726f9bf566d700527d21cb4 Mon Sep 17 00:00:00 2001 From: "Jules Sam. Randolph" Date: Tue, 29 Sep 2020 17:32:58 -0300 Subject: [PATCH] feat: support sending messages from shell to Web One can now pass a `webHandle` ref to shell components and use this handle to send messages to the web side. On the later, one can use `WebjsContext.onShellMessage` to register handlers. During feature creation, one must register a Web handler thanks to `FeatureBuilder.withWebHandler` and provide a `handlerId` to disambiguate between different messages. --- .../src/HandleLinkPressFeature.ts | 2 +- .../src/webshell-features.tstest.tsx | 32 +++ packages/core/etc/webshell.api.md | 80 ++++-- packages/core/src/Feature.ts | 49 +++- packages/core/src/FeatureBuilder.ts | 55 +++- packages/core/src/FeatureRegistry.ts | 117 ++++++++ packages/core/src/WebHandleImpl.ts | 60 ++++ .../src/__tests__/feat/DummyReceiver.webjs | 5 + .../src/__tests__/features-loader.test.tsx | 11 +- .../core/src/__tests__/make-webshell.test.tsx | 39 ++- packages/core/src/features-loader.webjs | 20 +- .../src/features/ForceElementSizeFeature.ts | 4 +- .../ForceResponsiveViewportFeature.ts | 2 +- .../features/HandleElementCSSBoxFeature.ts | 4 +- .../features/HandleHTMLDimensionsFeature.ts | 2 +- .../src/features/HandleHashChangeFeature.ts | 2 +- .../src/features/HandleLinkPressFeature.ts | 2 +- packages/core/src/index.ts | 3 + packages/core/src/make-webshell.tsx | 268 ++++++++---------- packages/core/src/types.ts | 94 +++++- 20 files changed, 629 insertions(+), 222 deletions(-) create mode 100644 packages/acceptance-tests/src/webshell-features.tstest.tsx create mode 100644 packages/core/src/FeatureRegistry.ts create mode 100644 packages/core/src/WebHandleImpl.ts create mode 100644 packages/core/src/__tests__/feat/DummyReceiver.webjs diff --git a/packages/acceptance-tests/src/HandleLinkPressFeature.ts b/packages/acceptance-tests/src/HandleLinkPressFeature.ts index d573fc36..1c28766a 100644 --- a/packages/acceptance-tests/src/HandleLinkPressFeature.ts +++ b/packages/acceptance-tests/src/HandleLinkPressFeature.ts @@ -10,7 +10,7 @@ export interface LinkPressTarget { uri: string; } -const defaultOptions: LinkPressOptions = { +const defaultOptions = { preventDefault: true }; diff --git a/packages/acceptance-tests/src/webshell-features.tstest.tsx b/packages/acceptance-tests/src/webshell-features.tstest.tsx new file mode 100644 index 00000000..66b099c0 --- /dev/null +++ b/packages/acceptance-tests/src/webshell-features.tstest.tsx @@ -0,0 +1,32 @@ +import React, { createRef } from 'react'; +import makeWebshell, { + FeatureBuilder, + WebHandle +} from '@formidable-webview/webshell'; +import WebView from 'react-native-webview'; + +const Feature1 = new FeatureBuilder({ + defaultOptions: {}, + featureIdentifier: 'test', + script: '' +}) + .withWebHandler<{ foo: string }, 'event1'>('event1') + .withWebHandler<{ bar: string }, 'event2'>('event2') + .build(); + +const feature1 = new Feature1(); + +const Webshell = makeWebshell(WebView, feature1); + +const webHandle = createRef(); + +webHandle.current?.postMessageToWeb(feature1, 'event1', { + foo: '' +}); + +webHandle.current?.postMessageToWeb(feature1, 'event2', { + bar: '' +}); + +// Should not throw error +; diff --git a/packages/core/etc/webshell.api.md b/packages/core/etc/webshell.api.md index 0ce4199d..6f32ac43 100644 --- a/packages/core/etc/webshell.api.md +++ b/packages/core/etc/webshell.api.md @@ -8,7 +8,7 @@ import type { ComponentPropsWithoutRef } from 'react'; import type { ComponentType } from 'react'; import type { ElementRef } from 'react'; import type { ForwardRefExoticComponent } from 'react'; -import * as React_2 from 'react'; +import type { Ref } from 'react'; import type { RefAttributes } from 'react'; // @public @@ -127,38 +127,47 @@ export type EventHandlerProps = { }; // @public -export abstract class Feature = []> implements FeatureDefinition { - constructor(params: FeatureDefinition & { - propSpecs: S; +export abstract class Feature = [], W extends WebHandlersSpecs = {}> implements FeatureDefinition { + // @internal + protected constructor(params: FeatureDefinition & { + propSpecs: P; + webSpecs: W; }, options: O); // (undocumented) - readonly defaultOptions: O; + readonly defaultOptions: Required; // (undocumented) readonly featureIdentifier: string; // (undocumented) + hasWebHandler(handlerId: string): boolean; + // (undocumented) readonly options: O; // (undocumented) - readonly propSpecs: S; + readonly propSpecs: P; readonly script: string; + // (undocumented) + readonly webSpecs: W; } // @public -export class FeatureBuilder = []> { +export class FeatureBuilder = [], W extends WebHandlersSpecs = {}> { constructor(config: FeatureBuilderConfig); - build(): FeatureConstructor; - withandlerProp(eventHandlerName: H, handlerId?: string): FeatureBuilder void) | undefined; }>] : [PropDefinition<{ [k_1 in H]?: ((p: P) => void) | undefined; }>, ...S[number][]]>; + build(): FeatureConstructor; + withandlerProp(propName: H, handlerId?: string): FeatureBuilder void) | undefined; }>] : [PropDefinition<{ [k_1 in H]?: ((p: P) => void) | undefined; }>, ...S[number][]], {}>; + withWebHandler

(handlerId: I): FeatureBuilder; }>; } // @public export interface FeatureBuilderConfig = []> extends FeatureDefinition { // @internal (undocumented) __propSpecs?: S; + // @internal (undocumented) + __webSpecs?: WebHandlersSpecs; } // @public -export interface FeatureConstructor = []> { +export interface FeatureConstructor = [], W extends WebHandlersSpecs = {}> { // (undocumented) - new (...args: O extends Partial ? [] | [O] : [O]): Feature; + new (...args: O extends Partial ? [] | [O] : [O]): Feature; // (undocumented) identifier: string; // (undocumented) @@ -169,11 +178,11 @@ export interface FeatureConstructor = [] export type FeatureDefinition = { readonly script: string; readonly featureIdentifier: string; - readonly defaultOptions: O; + readonly defaultOptions: Required; }; // @public -export type FeatureInstanceOf = F extends FeatureConstructor ? Feature : never; +export type FeatureInstanceOf = F extends FeatureConstructor ? Feature : never; // @public export const ForceElementSizeFeature: FeatureConstructor; @@ -274,7 +283,7 @@ export interface LinkPressTarget { } // @public -function makeWebshell, F extends Feature[]>(WebView: C, ...features: F): React_2.ForwardRefExoticComponent, F> & React_2.RefAttributes>>; +function makeWebshell, F extends Feature[]>(WebView: C, ...features: F): WebshellComponent; export default makeWebshell; @@ -304,7 +313,7 @@ export interface MinimalWebViewProps { readonly style?: unknown; } -// @public (undocumented) +// @public export type PropDefinition

>> = { handlerId: string; type: 'handler' | 'inert'; @@ -314,10 +323,10 @@ export type PropDefinition

>> = { }; // @public -export type PropsFromFeature = F extends Feature ? PropsFromSpecs : never; +export type PropsFromFeature = F extends Feature ? PropsFromSpecs : {}; // @public (undocumented) -export type PropsFromSpecs = S extends PropsSpecs ? S[number]['signature'] : never; +export type PropsFromSpecs = S extends PropsSpecs ? S[number] extends never ? {} : S[number]['signature'] : never; // @public (undocumented) export type PropsSpecs

= PropDefinition

[]; @@ -332,8 +341,31 @@ export interface VisualViewportDimensions { visualViewport: DOMRectSize; } +// @public (undocumented) +export interface WebHandle { + // Warning: (ae-forgotten-export) The symbol "WebHandlerSpecsFromFeature" needs to be exported by the entry point index.d.ts + // + // (undocumented) + postMessageToWeb, H extends keyof WebHandlerSpecsFromFeature>(feat: F, handlerId: H, payload: Required[H]>['payload']): void; +} + +// @public +export interface WebHandlerDefinition { + // (undocumented) + async: false; + // (undocumented) + handlerId: I; + // (undocumented) + payload?: P; +} + +// @public (undocumented) +export type WebHandlersSpecs

= { + [k in I]: WebHandlerDefinition; +}; + // @public -export interface WebjsContext { +export interface WebjsContext { getDOMSelection(selector: DOMElementRequest): HTMLElement | null; getDOMSelectionAll(selector: DOMElementQueryRequest | string): any; getDOMSelectionAll(selector: DOMElementClassNameRequest | DOMElementTagNameRequest): any; @@ -341,26 +373,28 @@ export interface WebjsContext { makeCallbackSafe(callback: T): T; // (undocumented) numericFromPxString(style: string): number; + onShellMessage

(handlerId: string, handler: (payload: P) => void): void; readonly options: O; - postMessageToShell(payload: P): void; - postMessageToShell(handlerId: string, payload: P): void; + postMessageToShell

(payload: P): void; + postMessageToShell

(handlerId: string, payload: P): void; warn(message: string): void; } // @public -export type WebshellComponent, F extends Feature[]> = ForwardRefExoticComponent, F> & RefAttributes>>; +export type WebshellComponent, F extends Feature[]> = ForwardRefExoticComponent, F> & RefAttributes>>; // @public -export type WebshellComponentOf, F extends FeatureConstructor[]> = WebshellComponent[]>; +export type WebshellComponentOf, F extends FeatureConstructor[]> = WebshellComponent[]>; // @public export interface WebshellInvariantProps { onDOMError?: (featureIdentifier: string, error: string) => void; + webHandle?: Ref; webshellDebug?: boolean; } // @public -export type WebshellProps[]> = WebshellInvariantProps & W & (F[number] extends never ? {} : PropsFromFeature); +export type WebshellProps[]> = WebshellInvariantProps & W & (F[number] extends never ? {} : PropsFromFeature); // (No @packageDocumentation comment for this package) diff --git a/packages/core/src/Feature.ts b/packages/core/src/Feature.ts index 22c0c0fc..408062a8 100644 --- a/packages/core/src/Feature.ts +++ b/packages/core/src/Feature.ts @@ -1,13 +1,18 @@ -import type { FeatureDefinition, PropsFromSpecs, PropsSpecs } from './types'; +import type { + FeatureDefinition, + PropsFromSpecs, + PropsSpecs, + WebHandlersSpecs +} from './types'; /** * A lookup type to infer the additional props from a feature. * * @public */ -export type PropsFromFeature = F extends Feature +export type PropsFromFeature = F extends Feature ? PropsFromSpecs - : never; + : {}; /** * A feature constructor function, aka class. @@ -16,9 +21,10 @@ export type PropsFromFeature = F extends Feature */ export interface FeatureConstructor< O extends {}, - S extends PropsSpecs = [] + S extends PropsSpecs = [], + W extends WebHandlersSpecs = {} > { - new (...args: O extends Partial ? [] | [O] : [O]): Feature; + new (...args: O extends Partial ? [] | [O] : [O]): Feature; name: string; identifier: string; } @@ -30,9 +36,10 @@ export interface FeatureConstructor< */ export type FeatureInstanceOf = F extends FeatureConstructor< infer O, - infer S + infer S, + infer W > - ? Feature + ? Feature : never; /** @@ -46,21 +53,39 @@ export type FeatureInstanceOf = F extends FeatureConstructor< * @typeparam S - Specifications for the new properties added to webshell. * @public */ -export abstract class Feature = []> - implements FeatureDefinition { +export abstract class Feature< + O extends {}, + P extends PropsSpecs = [], + W extends WebHandlersSpecs = {} +> implements FeatureDefinition { /** * {@inheritdoc FeatureDefinition.script} */ readonly script: string; readonly featureIdentifier: string; - readonly propSpecs: S; - readonly defaultOptions: O; + readonly propSpecs: P; + readonly webSpecs: W; + readonly defaultOptions: Required; readonly options: O; - constructor(params: FeatureDefinition & { propSpecs: S }, options: O) { + /** + * @internal + */ + protected constructor( + params: FeatureDefinition & { + propSpecs: P; + webSpecs: W; + }, + options: O + ) { this.script = params.script; this.featureIdentifier = params.featureIdentifier; this.propSpecs = params.propSpecs; this.defaultOptions = params.defaultOptions; this.options = { ...params.defaultOptions, ...options }; + this.webSpecs = params.webSpecs; + } + + hasWebHandler(handlerId: string) { + return !!this.webSpecs[handlerId]; } } diff --git a/packages/core/src/FeatureBuilder.ts b/packages/core/src/FeatureBuilder.ts index d9a9b7c2..ecb42857 100644 --- a/packages/core/src/FeatureBuilder.ts +++ b/packages/core/src/FeatureBuilder.ts @@ -1,7 +1,13 @@ /* eslint-disable no-spaced-func */ import { Feature } from './Feature'; import type { FeatureConstructor } from './Feature'; -import type { FeatureDefinition, PropDefinition, PropsSpecs } from './types'; +import type { + FeatureDefinition, + PropDefinition, + PropsSpecs, + WebHandlerDefinition, + WebHandlersSpecs +} from './types'; /** * See {@link FeatureBuilder}. @@ -16,6 +22,10 @@ export interface FeatureBuilderConfig< * @internal */ __propSpecs?: S; + /** + * @internal + */ + __webSpecs?: WebHandlersSpecs; } /** @@ -27,17 +37,21 @@ export interface FeatureBuilderConfig< * @typeparam S - Specifications for the new properties added by the built feature. * @public */ -export class FeatureBuilder = []> { +export class FeatureBuilder< + O extends {}, + S extends PropsSpecs = [], + W extends WebHandlersSpecs = {} +> { private config: FeatureBuilderConfig; public constructor(config: FeatureBuilderConfig) { this.config = config; } /** - * Signal that the feature will receive events from the Web, and the shell - * will provide a new handler prop. + * Instruct that the shell will receive events from the Web, and provide a + * new handler prop. * - * @param eventHandlerName - The name of the handler prop added to the shell. + * @param propName - The name of the handler prop added to the shell. * It is advised to follow the convention of prefixing all these handlers * with `onDom` to avoid collisions with `WebView` own props. * @@ -45,12 +59,12 @@ export class FeatureBuilder = []> { * script to post a message. If none is provided, fallback to `"default"`. */ withandlerProp( - eventHandlerName: H, + propName: H, handlerId: string = 'default' ) { const propDefinition: PropDefinition<{ [k in H]?: (p: P) => void }> = { handlerId, - name: eventHandlerName, + name: propName, featureIdentifier: this.config.featureIdentifier, type: 'handler' }; @@ -64,17 +78,37 @@ export class FeatureBuilder = []> { __propSpecs: [...(this.config.__propSpecs || []), propDefinition] as any }); } + /** + * Instruct that the Web script will receive events from the shell. + * See {@link WebshellInvariantProps.webHandle} and {@link WebjsContext.onShellMessage}. + * + * @param handlerId - The name of the handler in the Web script. + */ + withWebHandler

(handlerId: I) { + return new FeatureBuilder< + O, + S, + W & { [k in I]: WebHandlerDefinition } + >({ + ...this.config, + __webSpecs: { + ...(this.config.__webSpecs || {}), + [handlerId]: { async: false, handlerId } + } + }); + } /** * Assemble this configuration into a feature class. */ - build(): FeatureConstructor { + build(): FeatureConstructor { const { script, featureIdentifier, __propSpecs: propSpecs, + __webSpecs: webSpecs, defaultOptions } = this.config; - const ctor = class extends Feature { + const ctor = class extends Feature { static identifier = featureIdentifier; constructor(...args: O extends Partial ? [] | [O] : [O]) { super( @@ -82,7 +116,8 @@ export class FeatureBuilder = []> { script, featureIdentifier, defaultOptions, - propSpecs: (propSpecs || []) as S + propSpecs: (propSpecs || []) as S, + webSpecs: (webSpecs || {}) as W }, (args[0] || {}) as O ); diff --git a/packages/core/src/FeatureRegistry.ts b/packages/core/src/FeatureRegistry.ts new file mode 100644 index 00000000..dba54fe4 --- /dev/null +++ b/packages/core/src/FeatureRegistry.ts @@ -0,0 +1,117 @@ +import { Feature } from './Feature'; +import { PropDefinition, PropsSpecs, WebshellProps } from './types'; +import featuresLoaderScript from './features-loader.webjs'; + +function serializeFeature(feature: Feature>) { + return `{source:${feature.script},identifier:${JSON.stringify( + feature.featureIdentifier + )},options:${JSON.stringify(feature.options || {})}}`; +} + +function extractFeatureProps( + props: WebshellProps, + propsMap: Record>, + 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( + props: WebshellProps, + propsMap: Record> +): W { + return Object.keys(props).reduce((obj, key) => { + if (propsMap[key] || key.startsWith('webshell')) { + return obj; + } + return { + ...obj, + [key]: props[key] + }; + }, {} as W); +} + +function getHandlerUUID(identifier: string, handlerId: string) { + return `${identifier}:${handlerId}`; +} + +function extractHandlersMap(features: Feature>[]) { + return features + .map((f: Feature>) => f.propSpecs) + .reduce((p, c) => [...p, ...c], []) + .reduce( + (map, spec: PropDefinition) => ({ + ...map, + [getHandlerUUID(spec.featureIdentifier, spec.handlerId)]: spec + }), + {} + ) as Record>; +} + +function extractPropsSpecsMap(features: Feature>[]) { + return features + .map((f: Feature>) => f.propSpecs) + .reduce((p, c) => [...p, ...c], []) + .reduce( + (map, spec: PropDefinition) => ({ ...map, [spec.name]: spec }), + {} + ) as Record>; +} + +function registerFeature(feat: Feature) { + return `try { + window.ReactNativeWebshell.registerFeature(${serializeFeature(feat)}); + } catch (e) { + window.ReactNativeWebshell.sendErrorMessage(${JSON.stringify( + feat.featureIdentifier + )},e); + };`; +} + +export function assembleScript(feats: Feature[]) { + return `${featuresLoaderScript}(function(){${feats.map( + registerFeature + )};})();`; +} + +export class FeatureRegistry[]> { + readonly propsMap: Record>; + readonly handlersMap: Record>; + readonly assembledFeaturesScript: string; + readonly features: F; + constructor(features: F) { + const filteredFeatures = features.filter((f) => !!f); + this.propsMap = extractPropsSpecsMap(filteredFeatures); + this.handlersMap = extractHandlersMap(filteredFeatures); + this.assembledFeaturesScript = assembleScript(filteredFeatures); + this.features = features; + } + + getWebHandlers(props: WebshellProps) { + return extractFeatureProps(props, this.propsMap, 'handler'); + } + + getPropDefFromId(identifier: string, shellHandlerId: string) { + return this.handlersMap[getHandlerUUID(identifier, shellHandlerId)]; + } + + getPropDefFromHandlerName(handlerName: string) { + return this.propsMap[handlerName]; + } + + filterWebViewProps(webShellProps: WebshellProps) { + return filterWebViewProps(webShellProps, this.propsMap); + } + + hasFeature(feature: Feature) { + return this.features.indexOf(feature) !== -1; + } +} diff --git a/packages/core/src/WebHandleImpl.ts b/packages/core/src/WebHandleImpl.ts new file mode 100644 index 00000000..0db5137d --- /dev/null +++ b/packages/core/src/WebHandleImpl.ts @@ -0,0 +1,60 @@ +import { RefObject } from 'react'; +import { Feature } from './Feature'; +import { FeatureRegistry } from './FeatureRegistry'; +import { WebHandle, WebHandlerDefinition, WebHandlerSpecOf } from './types'; + +function javaScript(snippets: TemplateStringsArray, ...args: any[]) { + return snippets + .reduce((buffer, currentSnippet, index) => { + buffer.push(currentSnippet, JSON.stringify(args[index]) || ''); + return buffer; + }, [] as string[]) + .join(''); +} + +export class WebHandleImpl implements WebHandle { + private webViewRef: RefObject<{ + injectJavaScript: (js: string) => void; + }>; + private registry: FeatureRegistry; + constructor(webViewRef: RefObject, registry: FeatureRegistry) { + this.webViewRef = webViewRef; + this.registry = registry; + } + + protected injectJavaScript(snippets: TemplateStringsArray, ...args: any[]) { + if (this.webViewRef.current && !this.webViewRef.current.injectJavaScript) { + console.warn( + '[Webshell]: The WebView element you passed is missing injectJavaScript method.' + ); + return; + } + this.webViewRef.current?.injectJavaScript(javaScript(snippets, ...args)); + } + + postMessageToWeb< + D extends WebHandlerDefinition, + S extends WebHandlerSpecOf + >( + feat: Feature, + handlerId: D['handlerId'], + message: D['payload'] + ) { + if (__DEV__ && !feat.hasWebHandler(handlerId)) { + throw new Error( + `Feature ${feat.featureIdentifier} has no Web handler with ID "${handlerId}".` + ); + } + if (__DEV__ && !this.registry.hasFeature(feat)) { + throw new Error( + `Feature ${feat.featureIdentifier} has not be instantiated in this shell.` + ); + } + this + .injectJavaScript`window.ReactNativeWebshell.postMessageToWeb(${feat.featureIdentifier},${handlerId},${message});`; + } + + setDebug(debug: boolean) { + this.injectJavaScript`window.ReactNativeWebshell.debug=${debug};`; + } +} diff --git a/packages/core/src/__tests__/feat/DummyReceiver.webjs b/packages/core/src/__tests__/feat/DummyReceiver.webjs new file mode 100644 index 00000000..c1f18109 --- /dev/null +++ b/packages/core/src/__tests__/feat/DummyReceiver.webjs @@ -0,0 +1,5 @@ +function DummyReceiver(context) { + context.onShellMessage('hello', function (message) { + context.postMessageToShell(message); + }); +} diff --git a/packages/core/src/__tests__/features-loader.test.tsx b/packages/core/src/__tests__/features-loader.test.tsx index ad82e8e4..880110ff 100644 --- a/packages/core/src/__tests__/features-loader.test.tsx +++ b/packages/core/src/__tests__/features-loader.test.tsx @@ -4,8 +4,8 @@ import Ersatz from '@formidable-webview/ersatz'; import makeErsatzTesting from '@formidable-webview/ersatz-testing'; import { render } from '@testing-library/react-native'; import dummyHelloScript from './feat/DummyHello.webjs'; -import { assembleScript } from '../make-webshell'; import { FeatureBuilder } from '../FeatureBuilder'; +import { FeatureRegistry } from '../FeatureRegistry'; const { waitForErsatz } = makeErsatzTesting( Ersatz @@ -34,9 +34,14 @@ const eventShape = expect.objectContaining({ describe('Feature loader script', () => { it('should post messages sent from features', async () => { const onHello = jest.fn(); - const script = assembleScript([new HelloFeature()], true); + const registry = new FeatureRegistry([new HelloFeature()]); await waitForErsatz( - render() + render( + + ) ); expect(onHello).toHaveBeenCalledWith(eventShape); }); diff --git a/packages/core/src/__tests__/make-webshell.test.tsx b/packages/core/src/__tests__/make-webshell.test.tsx index 16c2bed0..7269c587 100644 --- a/packages/core/src/__tests__/make-webshell.test.tsx +++ b/packages/core/src/__tests__/make-webshell.test.tsx @@ -7,13 +7,20 @@ import dummyHelloScript from './feat/DummyHello.webjs'; import dummyFailingScript from './feat/DummyFailing.webjs'; import dummyOptionScript from './feat/DummyOption.webjs'; import dummyHandleridScript from './feat/DummyHandlerid.webjs'; +import dummyReceiverScript from './feat/DummyReceiver.webjs'; import { makeWebshell } from '../make-webshell'; import { FeatureBuilder } from '../FeatureBuilder'; -import { MinimalWebViewProps } from '../types'; +import { MinimalWebViewProps, WebHandle } from '../types'; +import { act } from 'react-test-renderer'; const { waitForErsatz } = makeErsatzTesting(Ersatz); -const DummyWebView = ({}: MinimalWebViewProps) => ; +const DummyWebView = React.forwardRef(({}: MinimalWebViewProps, ref) => { + React.useImperativeHandle(ref, () => ({ + injectJavaScript() {} + })); + return ; +}); const HelloFeature = new FeatureBuilder({ script: dummyHelloScript, @@ -47,6 +54,15 @@ const HandlerIdFeature = new FeatureBuilder({ .withandlerProp('onDOMDummyOption', 'hi') .build(); +const ReceiverFeature = new FeatureBuilder({ + script: dummyReceiverScript, + featureIdentifier: 'test.receiver', + defaultOptions: {} +}) + .withandlerProp('onWebFeedback') + .withWebHandler('hello') + .build(); + describe('Webshell component', () => { it('sould mount without error', () => { const Webshell = makeWebshell(DummyWebView, new HelloFeature()); @@ -105,6 +121,25 @@ describe('Webshell component', () => { ); expect(onHandlerIdDummyOption).toHaveBeenCalledWith('Hello world!'); }); + it('should support receiving messages', async () => { + const onWebFeedback = jest.fn(); + const feature = new ReceiverFeature(); + const Webshell = makeWebshell(Ersatz, feature); + const webHandle = React.createRef(); + await waitForErsatz( + render( + + ) + ); + act(() => { + webHandle.current?.postMessageToWeb(feature, 'hello', 'Hello world!'); + }); + expect(onWebFeedback).toHaveBeenCalledWith('Hello world!'); + }); it('should keep support for onMessage and injectedJavaScript', async () => { const onDOMDummyOption = jest.fn(); const onMessage = jest.fn(); diff --git a/packages/core/src/features-loader.webjs b/packages/core/src/features-loader.webjs index ee288d1d..68e6bb37 100644 --- a/packages/core/src/features-loader.webjs +++ b/packages/core/src/features-loader.webjs @@ -1,3 +1,5 @@ +var messagesHandlerRegistry = {}; + var safePostMessage = (function () { return window.ReactNativeWebView && typeof window.ReactNativeWebView.postMessage === 'function' @@ -8,9 +10,11 @@ var safePostMessage = (function () { throw 'Missing postMessage. You must run this script in a WebView or @formidable-webview/ersatz.'; }; })(); + function numericFromPxString(pixelString) { return pixelString ? parseFloat(pixelString.match(/[\d.]+/)) : 0; } + function makeCallbackSafe(onError, callback) { return function () { try { @@ -20,6 +24,7 @@ function makeCallbackSafe(onError, callback) { } }; } + function __getDOMSelection(request, multiple) { var normalRequest = typeof request === 'string' ? { query: request } : request; @@ -87,6 +92,8 @@ function sendErrorMessage(snippetIdentifier, e) { ); } +function registerShellMessageHandler(identifier, handlerId, handler) {} + function registerFeature(specs) { if (specs && typeof specs === 'object') { var executable = specs.source; @@ -95,6 +102,7 @@ function registerFeature(specs) { } else { return; } + messagesHandlerRegistry[specs.identifier] = {}; var context = { getDOMSelection: function (selection) { return __getDOMSelection(selection, false); @@ -107,6 +115,9 @@ function registerFeature(specs) { null, sendErrorMessage.bind(null, snippetIdentifier) ), + onShellMessage: function (handlerId, messageHandler) { + messagesHandlerRegistry[specs.identifier][handlerId] = messageHandler; + }, postMessageToShell: function () { var message = arguments.length > 1 ? arguments[1] : arguments[0]; var handlerId = arguments.length > 1 ? arguments[0] : 'default'; @@ -148,5 +159,12 @@ function registerFeature(specs) { window.ReactNativeWebshell = { debug: true, registerFeature: registerFeature, - sendErrorMessage: sendErrorMessage + sendErrorMessage: sendErrorMessage, + postMessageToWeb: function (identifier, handlerId, message) { + var identifierReg = messagesHandlerRegistry[identifier]; + if (identifierReg) { + var handler = identifierReg[handlerId]; + typeof handler === 'function' && handler(message); + } + } }; diff --git a/packages/core/src/features/ForceElementSizeFeature.ts b/packages/core/src/features/ForceElementSizeFeature.ts index 36aed1aa..deed977b 100644 --- a/packages/core/src/features/ForceElementSizeFeature.ts +++ b/packages/core/src/features/ForceElementSizeFeature.ts @@ -46,13 +46,13 @@ export interface ForceElementSizeOptions { shouldThrowWhenNotFound?: boolean; } -const defaultOptions: ForceElementSizeOptions = { +const defaultOptions: Required = { forceHeight: true, forceWidth: true, widthValue: 'auto', heightValue: 'auto', shouldThrowWhenNotFound: false -} as ForceElementSizeOptions; +} as Required; /** * This feature sets element size programmatically and only once, when diff --git a/packages/core/src/features/ForceResponsiveViewportFeature.ts b/packages/core/src/features/ForceResponsiveViewportFeature.ts index 4cb6ad92..dbb14f8b 100644 --- a/packages/core/src/features/ForceResponsiveViewportFeature.ts +++ b/packages/core/src/features/ForceResponsiveViewportFeature.ts @@ -16,7 +16,7 @@ export interface ForceResponsiveViewportOptions { maxScale?: number; } -const defaultOptions: ForceResponsiveViewportOptions = { +const defaultOptions: Required = { maxScale: 1 }; diff --git a/packages/core/src/features/HandleElementCSSBoxFeature.ts b/packages/core/src/features/HandleElementCSSBoxFeature.ts index cc6ba20d..4e054074 100644 --- a/packages/core/src/features/HandleElementCSSBoxFeature.ts +++ b/packages/core/src/features/HandleElementCSSBoxFeature.ts @@ -108,9 +108,9 @@ export interface ElementCSSBoxDimensions { verticalScrollbarWidth: number; } -const defaultOptions: HandleElementCSSBoxDimensionsOptions = { +const defaultOptions: Required = { shouldThrowWhenNotFound: false -} as HandleElementCSSBoxDimensionsOptions; +} as Required; /** * This feature enables receiving the {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Introduction_to_the_CSS_box_model | CSS Box dimensions} of an element in the diff --git a/packages/core/src/features/HandleHTMLDimensionsFeature.ts b/packages/core/src/features/HandleHTMLDimensionsFeature.ts index 96412620..ce0914c1 100644 --- a/packages/core/src/features/HandleHTMLDimensionsFeature.ts +++ b/packages/core/src/features/HandleHTMLDimensionsFeature.ts @@ -77,7 +77,7 @@ export interface HTMLDimensions { implementation: HTMLDimensionsImplementation; } -const defaultOptions: HandleHTMLDimensionsOptions = { +const defaultOptions: Required = { deltaMin: 0, forceImplementation: false, pollingInterval: 200 diff --git a/packages/core/src/features/HandleHashChangeFeature.ts b/packages/core/src/features/HandleHashChangeFeature.ts index aca0ecbd..98290d98 100644 --- a/packages/core/src/features/HandleHashChangeFeature.ts +++ b/packages/core/src/features/HandleHashChangeFeature.ts @@ -36,7 +36,7 @@ export interface HashChangeEvent { targetElementBoundingRect: DOMRect; } -const defaultOptions: HandleHashChangeOptions = { +const defaultOptions: Required = { shouldResetHashOnEvent: false }; diff --git a/packages/core/src/features/HandleLinkPressFeature.ts b/packages/core/src/features/HandleLinkPressFeature.ts index 9b0039c4..f425c143 100644 --- a/packages/core/src/features/HandleLinkPressFeature.ts +++ b/packages/core/src/features/HandleLinkPressFeature.ts @@ -69,7 +69,7 @@ export interface LinkPressTarget { }; } -const defaultOptions: LinkPressOptions = { +const defaultOptions: Required = { preventDefault: true, ignoreHashChange: true }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cc1dc765..766ec151 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,9 @@ export type { PropDefinition, PropsFromSpecs, PropsSpecs, + WebHandle, + WebHandlerDefinition, + WebHandlersSpecs, WebjsContext, WebshellComponent, WebshellComponentOf, diff --git a/packages/core/src/make-webshell.tsx b/packages/core/src/make-webshell.tsx index 9e9e515e..537e3b29 100644 --- a/packages/core/src/make-webshell.tsx +++ b/packages/core/src/make-webshell.tsx @@ -8,14 +8,14 @@ import type { } 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 + WebshellComponent } from './types'; +import { FeatureRegistry } from './FeatureRegistry'; +import { WebHandleImpl } from './WebHandleImpl'; interface WebViewMessage { data: string; @@ -47,84 +47,97 @@ function isPostMessageObject(o: unknown): o is PostMessage { ); } -function serializeFeature(feature: Feature>) { - return `{source:${feature.script},identifier:${JSON.stringify( - feature.featureIdentifier - )},options:${JSON.stringify(feature.options || {})}}`; -} - -function extractFeatureProps( - props: WebshellProps, - propsMap: Record>, - 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( - props: WebshellProps, - propsMap: Record> -): W { - return Object.keys(props).reduce((obj, key) => { - if (propsMap[key] || key.startsWith('webshell')) { - return obj; - } - return { - ...obj, - [key]: props[key] - }; - }, {} as W); -} - -function getHandlerUUID(def: PropDefinition) { - return `${def.featureIdentifier}:${def.handlerId}`; -} - -function extractHandlersMap(features: Feature>[]) { - return features - .map((f: Feature>) => f.propSpecs) - .reduce((p, c) => [...p, ...c], []) - .reduce( - (map, spec: PropDefinition) => ({ - ...map, - [getHandlerUUID(spec)]: spec - }), - {} - ) as Record>; -} +function useWebMessagesHandler( + registry: FeatureRegistry, + { + webshellDebug, + onDOMError, + onMessage, + ...otherProps + }: WebshellProps +) { + const domHandlers = React.useMemo(() => registry.getWebHandlers(otherProps), [ + otherProps, + registry + ]); -function extractPropsSpecsMap(features: Feature>[]) { - return features - .map((f: Feature>) => f.propSpecs) - .reduce((p, c) => [...p, ...c], []) - .reduce( - (map, spec: PropDefinition) => ({ ...map, [spec.name]: spec }), - {} - ) as Record>; + return React.useCallback( + ({ nativeEvent }: NativeSyntheticEvent) => { + const parsedJSON = parseJSONSafe(nativeEvent.data); + if (isPostMessageObject(parsedJSON)) { + const { type, identifier, body, handlerId, severity } = parsedJSON; + if (type === 'feature') { + const propDef = registry.getPropDefFromId(identifier, handlerId); + if (!propDef) { + console.warn( + `[Webshell]: script from feature "${identifier}" sent an event towards ${handlerId} handler, but there is ` + + 'no handler registered for this feature. ' + + 'Use FeatureBuilder.withHandlerProp to register that handler, or make ' + + 'sure its name is not misspell in the DOM script.' + ); + return; + } + const handlerName = propDef.name; + const handler = + typeof handlerId === 'string' ? domHandlers[handlerName] : null; + if (registry.getPropDefFromHandlerName(handlerName)) { + if (typeof handler === 'function') { + handler(body); + } else { + webshellDebug && + console.info( + `[Webshell]: script from feature "${identifier}" sent an event towards ${handlerId} handler, but there ` + + `is no handler prop named "${handlerName}" attached to the shell.` + ); + } + } else { + console.warn( + `[Webshell]: script from feature "${identifier}" sent an event towards ${handlerId} handler, but there is ` + + `no handler named "${handlerName}" defined with this handler ID. ` + + 'Use FeatureBuilder.withHandlerProp 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] + ); } -function registerFeature(feat: Feature) { - return `try { - window.ReactNativeWebshell.registerFeature(${serializeFeature(feat)}); - } catch (e) { - window.ReactNativeWebshell.sendErrorMessage(${JSON.stringify( - feat.featureIdentifier - )},e); - };`; +function useWebHandle( + webViewRef: React.RefObject, + registry: FeatureRegistry +) { + return React.useMemo( + (): WebHandleImpl => new WebHandleImpl(webViewRef, registry), + [webViewRef, registry] + ); } -export function assembleScript(feats: Feature[], debug: boolean) { - return `${featuresLoaderScript}(function(){${feats.map( - registerFeature - )};window.ReactNativeWebshell.debug=${debug};})();`; +function useJavaScript( + registry: FeatureRegistry, + injectedJavaScript: string +) { + return React.useMemo(() => { + const safeUserscript = + typeof injectedJavaScript === 'string' ? injectedJavaScript : ''; + return `(function(){${safeUserscript};${registry.assembledFeaturesScript};})();true;`; + }, [injectedJavaScript, registry]); } /** @@ -138,83 +151,33 @@ export function assembleScript(feats: Feature[], debug: boolean) { */ export function makeWebshell< C extends ComponentType, - F extends Feature[] ->( - WebView: C, - ...features: F -): React.ForwardRefExoticComponent< - WebshellProps, F> & - React.RefAttributes> -> { - const filteredFeatures = features.filter((f) => !!f); - const propsMap = extractPropsSpecsMap(filteredFeatures); - const handlersMap = extractHandlersMap(filteredFeatures); - const injectableScript = assembleScript(filteredFeatures, __DEV__); + F extends Feature[] +>(WebView: C, ...features: F): WebshellComponent { + const registry = new FeatureRegistry(features); const Webshell = ( props: WebshellProps, F> & { webViewRef: ElementRef } ) => { const { - onMessage, - onDOMError, - webshellDebug, + webHandle: webHandleRef, ...otherProps } = props as WebshellInvariantProps & MinimalWebViewProps; - const domHandlers = extractFeatureProps(otherProps, propsMap, 'handler'); - const handleOnMessage = React.useCallback( - ({ nativeEvent }: NativeSyntheticEvent) => { - const parsedJSON = parseJSONSafe(nativeEvent.data); - if (isPostMessageObject(parsedJSON)) { - const { type, identifier, body, handlerId, severity } = parsedJSON; - if (type === 'feature') { - const handlerName = handlersMap[`${identifier}:${handlerId}`].name; - const handler = - typeof handlerId === '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 towards ${handlerId} handler, but there ` + - `is no handler prop named "${handlerName}" attached to the shell.` - ); - } - } else { - console.warn( - `[Webshell]: script from feature "${identifier}" sent an event towards ${handlerId} handler, but there is ` + - `no handler named "${handlerName}" defined for this feature. ` + - 'Use FeatureBuilder.withHandlerProp 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]); + const { + webViewRef, + injectedJavaScript, + webshellDebug, + ...webViewProps + } = props; + const handleOnMessage = useWebMessagesHandler(registry, otherProps); + const resultingJavascript = useJavaScript(registry, injectedJavaScript); + const webHandle = useWebHandle(webViewRef, registry); + React.useImperativeHandle(webHandleRef, () => webHandle); + React.useEffect(() => webHandle.setDebug(webshellDebug), [ + webshellDebug, + webHandle + ]); return ( , WebshellProps, F> - >((props, ref) => ( - , F>)} - /> - )) as any; + >((props, ref) => { + const localWebViewRef = React.useRef(); + return ( + , F>)} + /> + ); + }) as any; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4ca39b56..24c2e203 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3,7 +3,8 @@ import type { ForwardRefExoticComponent, RefAttributes, ElementRef, - ComponentPropsWithoutRef + ComponentPropsWithoutRef, + Ref } from 'react'; import type { Feature, @@ -21,7 +22,7 @@ import type { */ export type WebshellComponent< C extends ComponentType, - F extends Feature[] + F extends Feature[] > = ForwardRefExoticComponent< WebshellProps, F> & RefAttributes> >; @@ -33,7 +34,7 @@ export type WebshellComponent< */ export type WebshellComponentOf< C extends ComponentType, - F extends FeatureConstructor[] + F extends FeatureConstructor[] > = WebshellComponent[]>; /** @@ -65,10 +66,49 @@ export type FeatureDefinition = { /** * These options will be shallow-merged with the options provided to the {@link FeatureConstructor}. */ - readonly defaultOptions: O; + readonly defaultOptions: Required; }; /** + * An object to define an API to send messages from shell to Web. + * + * @public + */ +export interface WebHandlerDefinition { + handlerId: I; + payload?: P; + async: false; +} + +/** + * @public + */ +export type WebHandlersSpecs

= { + [k in I]: WebHandlerDefinition; +}; + +/** + * @public + */ +export type WebHandlerSpecOf = S extends WebHandlerDefinition< + infer P, + infer I +> + ? { + [k in I]: WebHandlerDefinition; + } + : never; + +/** + * @public + */ +export type WebHandlerSpecsFromFeature = F extends Feature + ? P + : never; + +/** + * An object to define an API to send messages from Web to shell. + * * @public */ export type PropDefinition

>> = { @@ -83,7 +123,9 @@ export type PropDefinition

>> = { * @public */ export type PropsFromSpecs = S extends PropsSpecs - ? S[number]['signature'] + ? S[number] extends never + ? {} + : S[number]['signature'] : never; /** @@ -104,6 +146,26 @@ export type EventHandlerProps = { [k in H]?: (e: P) => void; }; +/** + * @public + */ +export interface WebHandle { + /** + * + * @param feat - The feature to which a message should be sent. + * @param handlerId - The handler identifier used in the Web script to register a listener. + * @param payload - The type of the message to sent. + */ + postMessageToWeb< + F extends Feature, + H extends keyof WebHandlerSpecsFromFeature + >( + feat: F, + handlerId: H, + payload: Required[H]>['payload'] + ): void; +} + /** * Props any Webshell component will support. * @@ -120,6 +182,10 @@ export interface WebshellInvariantProps { * @defaultvalue `__DEV__` */ webshellDebug?: boolean; + /** + * Pass a reference to send messages to the Web environment. + */ + webHandle?: Ref; } /** @@ -129,7 +195,7 @@ export interface WebshellInvariantProps { */ export type WebshellProps< W extends MinimalWebViewProps, - F extends Feature[] + F extends Feature[] > = WebshellInvariantProps & W & (F[number] extends never ? {} : PropsFromFeature); @@ -159,11 +225,10 @@ export interface MinimalWebViewProps { * This type specifies the shape of the object passed to Web features scripts. * * @typeparam O - The shape of the JSON-serializable options that will be passed to the Web script. - * @typeparam P - The type of the argument which will be passed to the event handler prop. * * @public */ -export interface WebjsContext { +export interface WebjsContext { /** * The options to customize the script behavior. */ @@ -174,16 +239,23 @@ export interface WebjsContext { * * @param payload - The value which will be passed to the handler. */ - postMessageToShell(payload: P): void; + postMessageToShell

(payload: P): void; /** * Instruct the shell to call the handler associated with this * feature and `eventId`, if any. * - * @param handlerId - A unique string to disambiguate between different handlers. + * @param handlerId - A unique string to disambiguate between different shell handlers. * You can omit this param if you are sending to `"default"` handler. * @param payload - The value which will be passed to the handler. */ - postMessageToShell(handlerId: string, payload: P): void; + postMessageToShell

(handlerId: string, payload: P): void; + /** + * Register a handler on messages sent from the shell. + * + * @param handlerId - A unique string to disambiguate between different Web handlers. + * @param payload - The value which will be passed to the handler. + */ + onShellMessage

(handlerId: string, handler: (payload: P) => void): void; /** * Create a function which execute a callback in a try-catch block that will * grab errors en send them to the `Webshell` component.