Every prop that isn't a host prop goes to the remote. Functions become async calls back to your page.
In the page, props are on rc.props. React pages get them as component props or from useProps().
Props
Props cross with the structured clone algorithm. Dates, Maps, Sets, typed arrays and Blobs arrive intact. Class instances lose their prototype. Symbols, Promises and DOM nodes are a data error.
Only changed props are sent, coalesced per task. The remote renders once per update.
Function props
A function prop becomes a proxy in the remote. So does a function inside a plain object or array prop. Calling it returns a Promise of your function's return value. If your function throws, the promise rejects.
const [name, setName] = useState("Ada");
const likes = useRef(0);
<input value={name} onChange={(e) => setName(e.target.value)} />
<RemoteComponent
src="https://widgets.example/greeting.html"
name={name}
onLike={(who: string) => {
likes.current += 1;
return likes.current; // the remote gets a Promise of this
}}
/><div class="row">
<span>Hello, <span id="who">there</span>.</span>
<button id="like">Like</button>
<span class="muted" id="likes"></span>
</div>
<script>
const render = () => (document.querySelector("#who").textContent = rc.props.name ?? "there");
render();
rc.addEventListener("props-changed", render);
document.querySelector("#like").onclick = async () => {
const likes = await rc.props.onLike(rc.props.name);
document.querySelector("#likes").textContent = `Host says ${likes} likes`;
};
</script>The proxy calls whatever function is at that prop now. Inline arrow functions are fine: a new identity sends no message, and the proxy stays stable for useEffect dependencies. A prop you removed fails with stale-function. So does any path you never sent a function at, such as one inside a prop that failed to send.
Arguments and return values must be data. Passing a function across fails with data.
The same remote as a React component:
import { useState } from "react";
interface Props {
name?: string;
onLike?: (name: string) => Promise<number>;
}
export default function Greeting({ name = "there", onLike }: Props) {
const [likes, setLikes] = useState<number | null>(null);
return (
<div className="row">
<span>Hello, {name}.</span>
<button onClick={() => void onLike?.(name).then(setLikes)}>Like</button>
{likes !== null && <span className="muted">Host says {likes} likes</span>}
</div>
);
}Cancellation
Pass an AbortSignal anywhere in the arguments. Your function gets its own signal in the same place. When the remote aborts, its call rejects at once and your signal aborts too.
<RemoteComponent
src="https://widgets.example/search.html"
onSearch={async (query: string, { signal }: { signal: AbortSignal }) => {
// signal aborts when the remote aborts its own signal.
await sleep(1500, signal);
return cities.filter((c) => c.toLowerCase().includes(query));
}}
/><div style="display: grid; gap: 8px">
<div class="row" style="flex-wrap: nowrap">
<input aria-label="Query" value="ber" style="flex: 1" />
<button id="search">Search</button>
<button id="cancel">Cancel</button>
</div>
<div class="muted" id="result">Idle</div>
</div>
<script>
const input = document.querySelector("input");
const result = document.querySelector("#result");
let controller = null;
document.querySelector("#search").onclick = async () => {
controller?.abort();
const ac = (controller = new AbortController());
result.textContent = "Searching…";
try {
const cities = await rc.props.onSearch(input.value, { signal: ac.signal });
result.textContent = cities.length ? cities.join(", ") : "No results";
} catch (error) {
result.textContent = ac.signal.aborted ? "Cancelled" : `Failed: ${error.message}`;
}
};
document.querySelector("#cancel").onclick = () => controller?.abort();
</script>Unmounting closes the channel. Calls in flight reject with closed, and calls the host is still serving get an aborted signal.