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
11 changes: 11 additions & 0 deletions .changeset/spicy-insects-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@remote-dom/tree-receiver': minor
'example-kitchen-sink': minor
'@remote-dom/polyfill': minor
'@remote-dom/preact': minor
'@remote-dom/core': minor
'@remote-dom/react': minor
'@remote-dom/signals': minor
---

test
37 changes: 11 additions & 26 deletions examples/kitchen-sink/app/host.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {render} from 'preact';
import {
RemoteRootRenderer,
RemoteFragmentRenderer,
createRemoteComponentRenderer,
} from '@remote-dom/preact/host';
import {Fragment, render} from 'preact';
import {
createThreadFromIframe,
createThreadFromWebWorker,
Expand Down Expand Up @@ -36,21 +31,14 @@ const workerSandbox = createThreadFromWebWorker<never, SandboxAPI>(worker);
// We will use Preact to render remote elements in this example. The Preact
// helper library lets you do this by mapping the name of a remote element to
// a local Preact component. We’ve implemented the actual UI of our components in
// the `./host/components.tsx` file, but we need to wrap each one in the `createRemoteComponentRenderer()`
// helper function in order to get some Preact niceties, like automatic conversion
// of slots to element props, and using the instance of a Preact component as the
// target for methods called on matching remote elements.
// the `./host/components.tsx` file, and here we're just mapping those components
// to the element names they should be exposed as on the remote side.
const components = new Map([
['ui-text', createRemoteComponentRenderer(Text)],
['ui-button', createRemoteComponentRenderer(Button)],
['ui-stack', createRemoteComponentRenderer(Stack)],
['ui-modal', createRemoteComponentRenderer(Modal)],
// 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
// element. The `RemoteFragmentRenderer` component is provided to render this element
// on the host.
['remote-fragment', RemoteFragmentRenderer],
['ui-text', Text],
['ui-button', Button],
['ui-stack', Stack],
['ui-modal', Modal],
['remote-fragment', Fragment],
]);

// We offload most of the complex state logic to this `createState()` function. We’re
Expand All @@ -60,7 +48,7 @@ const components = new Map([
// rendered by the remote environment. We use this object later to render these trees
// to Preact components using the `RemoteRootRenderer` component.

const {receiver, example, sandbox} = createState(
const {receiver, tree, example, sandbox} = createState(
async ({receiver, example, sandbox}) => {
if (sandbox === 'iframe') {
await iframeSandbox.render(receiver.connection, {
Expand All @@ -86,6 +74,7 @@ const {receiver, example, sandbox} = createState(
});
}
},
components,
);

// We render our Preact application, including the part that renders any remote
Expand All @@ -112,9 +101,5 @@ function ExampleRenderer() {
return <div>Error while rendering example: {value.message}</div>;
}

return (
<div>
<RemoteRootRenderer receiver={value} components={components} />
</div>
);
return <div>{tree}</div>;
}
2 changes: 2 additions & 0 deletions examples/kitchen-sink/app/host/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export function ControlPanel({
<option value="vanilla">“Vanilla” DOM</option>
<option value="preact">Preact</option>
<option value="react">React</option>
<option value="react-dom">React DOM</option>
<option value="svelte">Svelte</option>
<option value="vue">Vue</option>
<option value="htm">htm</option>
Expand Down Expand Up @@ -193,6 +194,7 @@ const EXAMPLE_FILE_NAMES = new Map<RenderExample, string>([
['htm', 'htm.ts'],
['preact', 'preact.tsx'],
['react', 'react.tsx'],
['react-dom', 'react-dom.tsx'],
['svelte', 'App.svelte'],
['vue', 'App.vue'],
]);
Expand Down
32 changes: 25 additions & 7 deletions examples/kitchen-sink/app/host/state.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {signal, effect} from '@preact/signals';
import {SignalRemoteReceiver} from '@remote-dom/preact/host';
import {retain, release} from '@quilted/threads';
import {PreactTreeReceiver} from '@remote-dom/tree-receiver/preact';

import type {RenderExample, RenderSandbox} from '../types.ts';
import {ComponentType} from 'preact';

const DEFAULT_SANDBOX = 'worker';
const ALLOWED_SANDBOX_VALUES = new Set<RenderSandbox>(['iframe', 'worker']);
Expand All @@ -13,6 +14,7 @@ const ALLOWED_EXAMPLE_VALUES = new Set<RenderExample>([
'htm',
'preact',
'react',
'react-dom',
'svelte',
'vue',
]);
Expand All @@ -21,8 +23,9 @@ export function createState(
render: (details: {
example: RenderExample;
sandbox: RenderSandbox;
receiver: SignalRemoteReceiver;
receiver: PreactTreeReceiver;
}) => void | Promise<void>,
components: Map<string, ComponentType>,
) {
const initialURL = new URL(window.location.href);

Expand All @@ -47,12 +50,22 @@ export function createState(
);

const receiver = signal<
SignalRemoteReceiver | Error | Promise<SignalRemoteReceiver> | undefined
PreactTreeReceiver | Error | Promise<PreactTreeReceiver> | undefined
>(undefined);

const tree = signal<JSX.Element>();

effect(() => {
const {value} = receiver;
if (!value || value instanceof Error || value instanceof Promise) return;
tree.value = value.resolved();
value.rerender = (jsx) => (tree.value = jsx);
return () => (value.rerender = Object);
});

const exampleCache = new Map<
string,
SignalRemoteReceiver | Error | Promise<SignalRemoteReceiver>
PreactTreeReceiver | Error | Promise<PreactTreeReceiver>
>();

effect(() => {
Expand All @@ -75,7 +88,12 @@ export function createState(
window.history.replaceState({}, '', newURL.toString());

if (cached == null) {
const receiver = new SignalRemoteReceiver({retain, release});
const receiver = new PreactTreeReceiver({
retain,
release,
components,
rerender() {},
});
cached = Promise.resolve(
render({
receiver,
Expand All @@ -97,7 +115,7 @@ export function createState(

receiver.value = cached;

function updateValueAfterRender(value: SignalRemoteReceiver | Error) {
function updateValueAfterRender(value: PreactTreeReceiver | Error) {
exampleCache.set(key, value);

if (sandboxValue !== sandbox.peek() || exampleValue !== example.peek()) {
Expand All @@ -110,5 +128,5 @@ export function createState(
}
});

return {receiver, sandbox, example};
return {receiver, tree, sandbox, example};
}
56 changes: 56 additions & 0 deletions examples/kitchen-sink/app/remote/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,59 @@ declare global {
'remote-fragment': InstanceType<typeof RemoteFragmentElement>;
}
}

// monkeypatch RemoteElement to remove all custom event handling
// so it just uses the standard EventTarget implementation.
const EVENT_HANDLER_PROPERTIES = Symbol('EVENT_HANDLER_PROPERTIES');
function strip(Ctor: any) {
const remoteElementProto = Object.getPrototypeOf(Ctor).prototype;
delete remoteElementProto.addEventListener;
delete remoteElementProto.removeEventListener;
delete remoteElementProto.dispatchEvent;
Ctor.remotePropertyDefinitions.forEach((value, key) => {
if (key.startsWith('_on')) {
Ctor.remotePropertyDefinitions.delete(key);
} else if (value.event) {
// we should probably just implement this in the React wrapper
const isReactStyle = key !== 'on' + value.event;
const proxyHandler = function (
this: Element & {[EVENT_HANDLER_PROPERTIES]: Record<string, Function>},
event: Event,
) {
const handlers = this[EVENT_HANDLER_PROPERTIES];
if (isReactStyle) return handlers?.[key]?.(event.detail, event);
return handlers?.[key]?.(event);
};

// remove the funky event handler behavior, but don't delete
// the "remote property definition" because the Preact+React
// wrapper code currently relies on it (it should probably
// just forward props by default instead!)
value.alias = undefined;
// install a standard event handler property
const type = value.event;
Object.defineProperty(Ctor.prototype, key, {
configurable: true,
enumerable: true,
get() {
return this[EVENT_HANDLER_PROPERTIES]?.[key] ?? null;
},
set(value) {
let handlers = this[EVENT_HANDLER_PROPERTIES];
if (!handlers) this[EVENT_HANDLER_PROPERTIES] = handlers = {};
const prev = handlers[key];
handlers[key] = value;
if (value && !prev) {
this.addEventListener(type, proxyHandler);
} else if (!value && prev) {
this.removeEventListener(type, proxyHandler);
}
},
});
}
});
}
strip(Text);
strip(Button);
strip(Stack);
strip(Modal);
89 changes: 89 additions & 0 deletions examples/kitchen-sink/app/remote/examples/react-dom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/** @jsxRuntime automatic */
/** @jsxImportSource react */

import {useState, useRef} from 'react';
import {createRoot} from 'react-dom/client';

import type {RenderAPI} from '../../types.ts';
import {RemoteElement} from '@remote-dom/core/elements';

type IgnoreKeys =
| symbol
| number
| 'children'
| keyof HTMLElement
| keyof RemoteElement;

type PropsForElement<T extends keyof HTMLElementTagNameMap> =
React.PropsWithChildren<{
[K in keyof HTMLElementTagNameMap[T] as `${K extends IgnoreKeys ? '' : K}`]?: HTMLElementTagNameMap[T][K];
}> & {
ref?: React.Ref<HTMLElementTagNameMap[T]>;
};

declare global {
namespace JSX {
interface IntrinsicElements {
'ui-stack': PropsForElement<'ui-stack'>;
'ui-text': PropsForElement<'ui-text'>;
'ui-button': PropsForElement<'ui-button'>;
'ui-modal': PropsForElement<'ui-modal'>;
}
}
}

export function renderUsingReactDOM(root: Element, api: RenderAPI) {
createRoot(root).render(<App api={api} />);
}

function App({api}: {api: RenderAPI}) {
return (
<ui-stack spacing>
<ui-text>
Rendering example: <ui-text emphasis>{api.example}</ui-text>
</ui-text>
<ui-text>
Rendering in sandbox: <ui-text emphasis>{api.sandbox}</ui-text>
</ui-text>
{/* <ui-button modal={<CountModal alert={api.alert} />}>Open modal</ui-button> */}
<ui-button>
Open modal
<CountModal slot="modal" alert={api.alert} />
</ui-button>
</ui-stack>
);
}

function CountModal({alert}: Pick<RenderAPI, 'alert'>) {
const [count, setCount] = useState(0);
const modalRef = useRef<HTMLElementTagNameMap['ui-modal']>(null);

return (
<ui-modal
ref={modalRef}
onClose={() => {
if (count > 0) {
alert(`You clicked ${count} times!`);
}

setCount(0);
}}
>
<ui-button slot="primaryAction" onPress={() => modalRef.current?.close()}>
Close
</ui-button>
<ui-stack spacing>
<ui-text>
Click count: <ui-text emphasis>{count}</ui-text>
</ui-text>
<ui-button
onPress={() => {
setCount((count) => count + 1);
}}
>
Click me!
</ui-button>
</ui-stack>
</ui-modal>
);
}
29 changes: 9 additions & 20 deletions examples/kitchen-sink/app/remote/examples/vanilla.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import type {RenderAPI} from '../../types.ts';
import {Modal} from '../elements.ts';

export function renderUsingVanillaDOM(root: Element, api: RenderAPI) {
let count = 0;

function handlePress() {
updateCount(count + 1);
}

function handleClose() {
if (count > 0) {
api.alert(`You clicked ${count} times!`);
Expand All @@ -21,10 +16,6 @@ export function renderUsingVanillaDOM(root: Element, api: RenderAPI) {
countText.textContent = String(count);
}

function handlePrimaryAction() {
modal.close();
}

const countText = document.createElement('ui-text');
countText.textContent = String(count);
countText.setAttribute('emphasis', '');
Expand All @@ -34,27 +25,25 @@ export function renderUsingVanillaDOM(root: Element, api: RenderAPI) {
template.innerHTML = `
<ui-modal slot="modal">
<ui-text>Click count: </ui-text>
<ui-button>Click me!</ui-button>
<ui-button slot="primaryAction">
<ui-button id="increment">Click me!</ui-button>
<ui-button id="close" slot="primaryAction">
Close
</ui-button>
</ui-modal>
`.trim();

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

modal.addEventListener('close', handleClose);

modal.querySelector('ui-text')!.append(countText);

const [countButton, primaryActionButton] = [
...modal.querySelectorAll('ui-button'),
];

countButton!.addEventListener('press', handlePress);
primaryActionButton!.addEventListener('press', handlePrimaryAction);
root.addEventListener('press', (event) => {
const id = (event.target as Element).getAttribute('id');
// console.log(id, event);
if (id === 'increment') updateCount(count + 1);
if (id === 'close') modal.close();
});

template.innerHTML = `
<ui-stack spacing>
Expand Down
Loading