Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: couple refs to outer application DOM elements #409

Merged
merged 4 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/application/src/hooks/useComponentTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,23 @@ export function useComponentTree({
onCallbackResponse({ data, onMessageSent });
break;
}
case 'component.domMethodInvocation': {
// look up the element for this ref ID
const selector = `[data-roc-ref-id="${data.id}"]`;
const element = document.querySelector(selector);
if (!element) {
console.error(`no element found for ref id ${data.id}`);
return;
}

// invoke the DOM method
const method = element[data.method as keyof Element] as Function;
if (typeof method === 'function') {
method.call(element, ...data.args);
}

break;
}
case 'component.render': {
const { childComponents, containerId, node } = data;

Expand Down
13 changes: 12 additions & 1 deletion packages/common/src/types/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import type { ComponentTrust } from './trust';
type ComponentCallbackInvocationType = 'component.callbackInvocation';
type ComponentCallbackResponseType = 'component.callbackResponse';
type ComponentDomCallbackType = 'component.domCallback';
type ComponentDomMethodInvocationType = 'component.domMethodInvocation';
type ComponentRenderType = 'component.render';
type ComponentUpdateType = 'component.update';
export type EventType =
| ComponentCallbackInvocationType
| ComponentCallbackResponseType
| ComponentDomCallbackType
| ComponentDomMethodInvocationType
| ComponentRenderType
| ComponentUpdateType;

Expand Down Expand Up @@ -62,13 +64,22 @@ export interface DomCallback {
type: ComponentDomCallbackType;
}

export interface DomMethodInvocation {
args: SerializedArgs;
containerId: string;
id: string;
method: string;
type: ComponentDomMethodInvocationType;
}

// payloads sent by the application to a container
export type ApplicationPayload = ComponentUpdate | DomCallback;

// payloads sent by a container to the application
export type ContainerPayload =
| ComponentCallbackInvocation
| ComponentCallbackResponse
| ComponentRender;
| ComponentRender
| DomMethodInvocation;

export type MessagePayload = ApplicationPayload | ContainerPayload;
3 changes: 3 additions & 0 deletions packages/container/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function initContainer({
postCallbackInvocationMessage,
postCallbackResponseMessage,
postComponentRenderMessage,
postDomMethodInvocationMessage,
} = composeMessagingMethods();

const { deserializeArgs, deserializeProps, serializeArgs, serializeNode } =
Expand All @@ -57,6 +58,8 @@ export function initContainer({
isFragment: (c) => c === Fragment,
isRootComponent: (c) => !!c?.isRootContainerComponent,
postComponentRenderMessage,
postDomMethodInvocationMessage,
serializeArgs,
serializeNode,
trust,
});
Expand Down
23 changes: 21 additions & 2 deletions packages/container/src/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import type {
ComponentCallbackInvocation,
ComponentCallbackResponse,
ComponentRender,
DomMethodInvocation,
PostMessageParams,
} from '@bos-web-engine/common';

import type {
CallbackRequest,
ComposeMessagingMethodsCallback,
PostMessageComponentCallbackInvocationParams,
PostMessageComponentCallbackResponseParams,
PostMessageComponentRenderParams,
PostMessageDomMethodInvocationParams,
} from './types';

export function buildRequest(): CallbackRequest {
Expand All @@ -27,7 +30,7 @@ export function buildRequest(): CallbackRequest {
};
}

export function composeMessagingMethods() {
export const composeMessagingMethods: ComposeMessagingMethodsCallback = () => {
function postMessage<T extends PostMessageParams>(message: T) {
window.parent.postMessage(message, '*');
}
Expand Down Expand Up @@ -88,9 +91,25 @@ export function composeMessagingMethods() {
});
}

function postDomMethodInvocationMessage({
args,
containerId,
id,
method,
}: PostMessageDomMethodInvocationParams): void {
postMessage<DomMethodInvocation>({
args,
containerId,
id,
method,
type: 'component.domMethodInvocation',
});
}

return {
postCallbackInvocationMessage,
postCallbackResponseMessage,
postComponentRenderMessage,
postDomMethodInvocationMessage,
};
}
};
60 changes: 60 additions & 0 deletions packages/container/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
BWEComponentNode,
ComposeRenderMethodsCallback,
ContainerComponent,
ElementRef,
Node,
PlaceholderNode,
} from './types';
Expand All @@ -22,9 +23,13 @@ export const composeRenderMethods: ComposeRenderMethodsCallback = ({
isComponent,
isFragment,
postComponentRenderMessage,
postDomMethodInvocationMessage,
serializeArgs,
serializeNode,
trust,
}) => {
const elementRefs = new Map<HTMLElement, any>();

const dispatchRender: DispatchRenderCallback = (node) => {
const serializedNode = serializeNode({
node: node as Node,
Expand Down Expand Up @@ -86,6 +91,40 @@ export const composeRenderMethods: ComposeRenderMethodsCallback = ({
};
};

/**
* Construct a record for tracking element refs
* @param element HTML element bound to a Component via `ref`
*/
function buildElementRef(element: HTMLElement): ElementRef {
const id = window.crypto.randomUUID();
return {
id,
proxy: new Proxy(element, {
get(target: HTMLElement, p: string | symbol): any {
const prop = target[p as keyof typeof target];
if (typeof prop !== 'function') {
return prop;
}

// replace methods with a wrapper function bound to the element that
// posts a DOM method invocation message to the outer application
function intercepted(...args: any[]) {
postDomMethodInvocationMessage({
args: serializeArgs({ args, containerId }),
containerId,
id,
method: p as string,
});
return (prop as Function).call(target, ...args);
}

return intercepted.bind(target);
},
}),
ref: element,
};
}

function parseRenderedTree(
node: RenderedVNode | null,
renderedChildren?: Array<RenderedVNode | null>
Expand All @@ -94,6 +133,27 @@ export const composeRenderMethods: ComposeRenderMethodsCallback = ({
return node;
}

/*
for elements bound to `ref` instances, create a proxy object to forward
DOM method invocations in the iframe to the outer application
*/
if (node.ref) {
// @ts-expect-error
const element: HTMLElement = node.ref.current;
if (!(element instanceof HTMLElement)) {
console.error('unexpected ref type', element);
}

if (!elementRefs.has(element)) {
elementRefs.set(element, buildElementRef(element));
}

const { id, proxy } = elementRefs.get(element)!;
// @ts-expect-error
node.ref.current = proxy;
node.props['data-roc-ref-id'] = id;
}

const component = node.type as ContainerComponent;
let rootComponentChildren = isRootComponent(component)
? renderedChildren
Expand Down
24 changes: 21 additions & 3 deletions packages/container/src/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,26 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback =
componentId,
].join('::');

/**
* Mark kebab keys as duplicates when they exist as camel cased on props
* TODO find where do these come from
* @param key props key
* @param props Component props
*/
const isDuplicateKey = (key: string, props: any) => {
return (
key
.split('-')
.reduce(
(propKey, word, i) =>
`${propKey}${
i ? `${word[0].toUpperCase()}${word.slice(1)}` : word
}`,
''
) in props
);
};

/**
* Serialize props of a child Component to be rendered in the outer application
* @param containerId Component's parent container
Expand All @@ -156,9 +176,7 @@ export const composeSerializationMethods: ComposeSerializationMethodsCallback =
}) => {
return Object.entries(props).reduce(
(newProps, [key, value]: [string, any]) => {
// TODO remove invalid props keys at the source
// (probably JSX transpilation)
if (key === 'class' || key.includes('-')) {
if (key === 'class' || isDuplicateKey(key, props)) {
return newProps;
}

Expand Down
19 changes: 19 additions & 0 deletions packages/container/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,35 @@ export interface PostMessageComponentRenderParams {
trust: ComponentTrust;
}

export type PostDomMethodInvocationCallback = (
message: PostMessageDomMethodInvocationParams
) => void;
export interface PostMessageDomMethodInvocationParams {
args: any[];
containerId: string;
id: string;
method: string;
}

export interface ContainerComponent extends FunctionComponent {
isRootContainerComponent: boolean;
}

export interface ElementRef {
id: string;
proxy: HTMLElement;
ref: HTMLElement;
}

interface ComposeRenderMethodsParams {
containerId: string;
isComponent: (component: Function) => boolean;
isExternalComponent: (component: ContainerComponent) => boolean;
isFragment: (component: Function) => boolean;
isRootComponent: (component: ContainerComponent) => boolean;
postComponentRenderMessage: PostMessageComponentRenderCallback;
postDomMethodInvocationMessage: PostDomMethodInvocationCallback;
serializeArgs: SerializeArgsCallback;
serializeNode: SerializeNodeCallback;
trust: ComponentTrust;
}
Expand Down Expand Up @@ -127,6 +145,7 @@ export type ComposeMessagingMethodsCallback = () => {
postCallbackInvocationMessage: PostMessageComponentInvocationCallback;
postCallbackResponseMessage: PostMessageComponentResponseCallback;
postComponentRenderMessage: PostMessageComponentRenderCallback;
postDomMethodInvocationMessage: PostDomMethodInvocationCallback;
};

export type UpdateContainerPropsCallback = (props: Props) => void;
Expand Down
Loading