Skip to content
Draft
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
10 changes: 9 additions & 1 deletion examples/kitchen-sink/app/host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import {
import {ThreadIframe, ThreadWebWorker} from '@quilted/threads';

import type {SandboxAPI} from './types.ts';
import {Button, Modal, Stack, Text, ControlPanel} from './host/components.tsx';
import {
Button,
Modal,
Stack,
Text,
ControlPanel,
Iframe,
} from './host/components.tsx';
import {createState} from './host/state.ts';

// We will put any remote elements we want to render in this root element.
Expand Down Expand Up @@ -42,6 +49,7 @@ const components = new Map([
['ui-button', createRemoteComponentRenderer(Button)],
['ui-stack', createRemoteComponentRenderer(Stack)],
['ui-modal', createRemoteComponentRenderer(Modal)],
['ui-iframe', createRemoteComponentRenderer(Iframe)],
// The `remote-fragment` element is a special element created by Remote DOM when
// it needs an unstyled container for a list of elements. This is primarily used
// to convert elements passed as a prop to React or Preact components into a slotted
Expand Down
31 changes: 30 additions & 1 deletion examples/kitchen-sink/app/host/components.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {type ComponentChildren} from 'preact';
import {forwardRef} from 'preact/compat';
import {useRef, useImperativeHandle} from 'preact/hooks';
import {useRef, useImperativeHandle, useEffect} from 'preact/hooks';
import type {Signal} from '@preact/signals';

import type {
Expand Down Expand Up @@ -233,3 +233,32 @@ function nanoId(size = 21) {
}
return id;
}

export function Iframe({src, onReady}: {src: string; onReady: any}) {
const iframe = useRef<HTMLIFrameElement | null>(null);

useEffect(() => {
const iframeRef = iframe.current;
if (!iframeRef || !iframeRef.contentWindow) {
return;
}
iframeRef.addEventListener('message', (event) => {
const origin = new URL(src).origin;
iframeRef.contentWindow?.postMessage(event.data, origin);
});
}, []);

return (
<iframe
style={{outline: 'none', height: '100%', width: '100%', border: 'none'}}
ref={iframe}
src={src}
onLoad={() => {
// onload should be called only after the app bridge handshake happens, this is just an artificial delay
setTimeout(() => {
onReady?.(new Event('ready'));
}, 100);
}}
/>
);
}
24 changes: 23 additions & 1 deletion examples/kitchen-sink/app/remote/elements.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {
createRemoteElement,
RemoteEvent,
RemoteRootElement,
RemoteFragmentElement,
type RemoteEvent,
RemoteElement,
} from '@remote-dom/core/elements';

import type {
Expand All @@ -12,13 +13,32 @@ import type {
ModalMethods,
StackProperties,
} from '../types.ts';
import {dispatchEventToHost} from '../../../../packages/core/source/elements/internals.ts';

// In this file we will define the custom elements that can be rendered in the
// remote environment. Note that none of these elements have any real implementation —
// they just act as placeholders that will be communicated to the host environment.
// The host environment contains the actual implementation of these elements (in this case,
// they have been implemented using Preact, in the `host/components.tsx` file).

export class Iframe extends RemoteElement {
static get remoteAttributes() {
return ['src'];
}

static get remoteEvents() {
return ['ready'];
}

get contentWindow() {
return {
postMessage: (data: any) => {
dispatchEventToHost(this, 'message', {data});
},
};
}
}

export const Text = createRemoteElement<TextProperties>({
properties: {
emphasis: {type: Boolean},
Expand Down Expand Up @@ -56,13 +76,15 @@ customElements.define('ui-text', Text);
customElements.define('ui-button', Button);
customElements.define('ui-modal', Modal);
customElements.define('ui-stack', Stack);
customElements.define('ui-iframe', Iframe);

declare global {
interface HTMLElementTagNameMap {
'ui-text': InstanceType<typeof Text>;
'ui-button': InstanceType<typeof Button>;
'ui-stack': InstanceType<typeof Stack>;
'ui-modal': InstanceType<typeof Modal>;
'ui-iframe': InstanceType<typeof Iframe>;
}
}

Expand Down
17 changes: 17 additions & 0 deletions examples/kitchen-sink/app/remote/examples/vanilla.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export function renderUsingVanillaDOM(root: Element, api: RenderAPI) {
function updateCount(newCount: number) {
count = newCount;
countText.textContent = String(count);
iframe.contentWindow?.postMessage({
count,
});
}

function handlePrimaryAction() {
Expand All @@ -38,13 +41,27 @@ export function renderUsingVanillaDOM(root: Element, api: RenderAPI) {
<ui-button slot="primaryAction">
Close
</ui-button>
<ui-iframe/>
</ui-modal>
`.trim();

const modal = template.querySelector('ui-modal')! as InstanceType<
typeof Modal
>;

const iframe = template.querySelector('ui-iframe');

iframe.addEventListener('ready', () => {
iframe.contentWindow?.postMessage({
count,
});
});

iframe.setAttribute(
'src',
'https://emacs-since-invention-shannon.trycloudflare.com/app',
);

modal.addEventListener('close', handleClose);

modal.querySelector('ui-text')!.append(countText);
Expand Down
1 change: 0 additions & 1 deletion examples/kitchen-sink/app/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,6 @@ li {
display: grid;
gap: 1rem;
padding: 1rem;
justify-content: start;
}

.Modal-Actions {
Expand Down
18 changes: 18 additions & 0 deletions packages/core/source/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import type {
export type {RemoteConnection};

export interface RemoteConnectionHandler {
/**
* Triggers events on the host component.
*/
dispatchEvent: <E extends Event>(id: string, event: E) => void;

/**
* Handles the `call()` operation on the `RemoteConnection`.
*/
Expand Down Expand Up @@ -62,6 +67,7 @@ export interface RemoteConnectionHandler {
* naming on top of the protocol.
*/
export function createRemoteConnection({
dispatchEvent,
call,
insertChild,
removeChild,
Expand All @@ -76,6 +82,9 @@ export function createRemoteConnection({
};

return {
dispatchEvent: (id, type, details) => {
dispatchEvent(id, constructEvent(type, details));
},
call,
mutate(records) {
for (const [type, ...args] of records) {
Expand All @@ -84,3 +93,12 @@ export function createRemoteConnection({
},
};
}

export function constructEvent(type: string, eventDict: Record<string, any>) {
switch (type) {
case 'message':
return new MessageEvent(type, eventDict);
default:
return new Event(type, eventDict);
}
}
6 changes: 6 additions & 0 deletions packages/core/source/elements/RemoteElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,12 @@ export abstract class RemoteElement<

Object.defineProperties(this, propertyDescriptors);
Object.assign(this, initialPropertiesToSet);

// this.contentWindow = {
// postMessage: (data: any) => {
// dispatchEventToHost(this, 'message', data);
// },
// };
}

attributeChangedCallback(attribute: string, _oldValue: any, newValue: any) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/source/elements/RemoteReceiverElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class RemoteReceiverElement extends HTMLElement {

const receiver = new DOMRemoteReceiver({
root: this,
dispatchEvent: receiver.dispatchEvent,
call: (element, method, ...args) =>
this.call
? this.call(element, method, ...args)
Expand Down
15 changes: 15 additions & 0 deletions packages/core/source/elements/internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,18 @@ export function callRemoteElementMethod(

return connection.call(id, method, ...args);
}

export function dispatchEventToHost(
node: Element,
type: string,
details: Record<string, any>,
) {
const id = REMOTE_IDS.get(node);
const connection = REMOTE_CONNECTIONS.get(node);

if (id == null || connection == null) {
throw new Error('node not found or not connected');
}

return connection.dispatchEvent(id, type, details);
}
5 changes: 5 additions & 0 deletions packages/core/source/polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,8 @@ hooks.removeAttribute = (element, name) => {
};

export {hooks, window, type Hooks};

// Object.defineProperty(window, 'HTMLIFrameElement', {
// value: HTMLIFrameElement,
// configurable: true,
// });
4 changes: 4 additions & 0 deletions packages/core/source/receivers/DOMRemoteReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export class DOMRemoteReceiver {
const destroyTimeouts = new Map<string, number>();

this.connection = createRemoteConnection({
dispatchEvent: (id, event) => {
const implementation = attached.get(id) as Node;
implementation.dispatchEvent(event);
},
call: (id, method, ...args) => {
const element =
id === ROOT_ID && this.root.nodeType !== 11
Expand Down
6 changes: 6 additions & 0 deletions packages/core/source/receivers/RemoteReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ export class RemoteReceiver {
const {attached, parents, subscribers} = this;

this.connection = createRemoteConnection({
dispatchEvent: (id, type, details) => {
const element = attached.get(id);
if (element) {
element.dispatchEvent(new Event(type, {...details}));
}
},
call: (id, method, ...args) => {
const implementation = this.implementations.get(id);
const implementationMethod = implementation?.[method];
Expand Down
6 changes: 6 additions & 0 deletions packages/core/source/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export interface RemoteConnection {
* @returns The return value of the method.
*/
call(id: string, method: string, ...args: readonly unknown[]): unknown;

dispatchEvent(
id: string,
type: string,
details: Record<string, any>,
): unknown;
}

/**
Expand Down
17 changes: 15 additions & 2 deletions packages/preact/source/host/component.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type {ComponentType} from 'preact';
import {memo, useRef, useEffect, type MutableRefObject} from 'preact/compat';
import {
memo,
useRef,
useEffect,
type MutableRefObject,
findDOMNode,
} from 'preact/compat';
import type {RemoteReceiverElement} from '@remote-dom/core/receivers';

import {
Expand Down Expand Up @@ -112,7 +118,14 @@ export function createRemoteComponentRenderer<
};
}, [id, receiver]);

return <Component ref={internalsRef.current.instanceRef} {...props} />;
return (
<Component
ref={(node) => {
internalsRef.current.instanceRef.current = findDOMNode(node) || node;
}}
{...props}
/>
);
});

RemoteComponentRenderer.displayName =
Expand Down
7 changes: 7 additions & 0 deletions packages/signals/source/SignalRemoteReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ export class SignalRemoteReceiver {
const {attached, parents} = this;

const baseConnection = createRemoteConnection({
dispatchEvent: (id, event) => {
const implementation = this.implementations.get(
id,
) as unknown as Element;
implementation.dispatchEvent(event);
},
call: (id, method, ...args) => {
const implementation = this.implementations.get(id);
const implementationMethod = implementation?.[method];
Expand Down Expand Up @@ -232,6 +238,7 @@ export class SignalRemoteReceiver {
});

this.connection = {
dispatchEvent: baseConnection.dispatchEvent,
call: baseConnection.call,
mutate(records) {
batch(() => {
Expand Down