Skip to content

RPC and events

This iframe is a separate page. The buttons call its methods; its buttons call this page and emit events. Everything goes over the real handshake.

A contract names one side’s methods and the events it emits. Share it between both code bases:

import type { Side } from 'react-iframe-kit';
export type ParentSide = Side<{
methods: { navigate(path: string): void; getUser(): User };
events: { themeChanged: 'light' | 'dark'; closed: void };
}>;
export type ChildSide = Side<{
methods: { setTheme(theme: 'light' | 'dark'): void };
events: { submitted: { id: string } };
}>;
  • Every remote method returns a promise: getUser(): User is called as await remote.getUser().
  • An event with a void payload is emitted without one: emit('closed').
  • Values are copied with the structured clone algorithm. So Side<> makes functions in arguments, return values or payloads a compile error, and then/toJSON can’t be method names. Some losses can’t be expressed in types: class instances arrive as plain objects, and getters and symbol keys are dropped.

Generic arguments always come in the order <Remote, Local>: the other side first.

import { useIframeEvent, useIframeRPC } from 'react-iframe-kit';
const { remote, emit, status, error } = useIframeRPC<ChildSide, ParentSide>(ref, {
origin: 'https://widget.example.com', // optional: defaults to the origin of src
methods: { navigate, getUser },
timeout: 10_000, // per call, from send to result
connectTimeout: 30_000, // how long a call may wait for the connection
});
await remote.setTheme('dark');
emit('themeChanged', 'dark');
useIframeEvent<ChildSide, 'submitted'>(ref, 'submitted', ({ id }) => save(id));

status is 'idle' (no iframe element yet), 'connecting', 'connected', 'timeout' or 'error'. On 'error', error holds the configuration problem, such as RIK_ORIGIN_CONFLICT or RIK_METHOD_CONFLICT. Hooks never throw.

'timeout' means the iframe has been 'connecting' for longer than connectTimeout, which is where to show “the widget didn’t load” instead of a spinner. It isn’t final: if the handshake completes later, status moves on to 'connected'. For an iframe with loading="lazy" the time counts from its first load, so an iframe below the fold doesn’t time out before it starts loading. Queued calls still count from the moment they were made; pass a longer connectTimeout (or Infinity) for lazy iframes you call early.

if (status === 'timeout') return <a href={widgetUrl} target="_blank">Open the widget</a>;

Without React:

import { connectToParent } from 'react-iframe-kit/child';
const parent = connectToParent<ParentSide, ChildSide>({
allowedOrigins: ['https://app.example.com'], // required
methods: { setTheme },
autoResize: true,
});
const user = await parent.remote.getUser();
const off = parent.on('themeChanged', applyTheme);
parent.emit('submitted', { id });
await parent.whenConnected();
parent.dispose();

With React:

import { useParent, useParentEvent } from 'react-iframe-kit/child/react';
const { remote, emit, status } = useParent<ParentSide, ChildSide>({
allowedOrigins: ['https://app.example.com'],
methods: { setTheme },
});
useParentEvent<ParentSide, 'themeChanged'>('themeChanged', applyTheme);

Several connectToParent/useParent calls on one page share a single connection. Their methods are merged, and registering one name twice is RIK_METHOD_CONFLICT.

  • Calls before connecting are queued, in order with events, and flushed on connect. A queued call rejects with RIK_TIMEOUT (phase: 'connect') after connectTimeout.

  • timeout starts when the call is actually sent, so a slow iframe load doesn’t eat into it.

  • Per call: withOptions(remote.method, { timeout, signal }). timeout: Infinity is allowed, e.g. for “open a dialog and resolve with the choice”. An aborted signal rejects with signal.reason.

    import { withOptions } from 'react-iframe-kit';
    const choice = await withOptions(remote.confirm, { timeout: Infinity })('Delete?');
  • Errors thrown by a remote method arrive as RemoteError. Its cause has the remote name, message, code and own data:

    try {
    await remote.save(draft);
    } catch (error) {
    if (error instanceof RemoteError && error.cause.code === 'E_CONFLICT') retry();
    }
  • Losing the connection (the iframe reloads or navigates) rejects the calls that were already sent with RIK_CONNECTION_LOST. Queued calls wait for the next connection.

  • Events aren’t buffered on the receiving side: an event with no handler registered is dropped.

  • remote and emit keep the same identity across renders, and the latest methods and handlers are always used without reconnecting.

  • Large binary data: transfer(value, [buffer]) moves an ArrayBuffer, MessagePort or similar instead of copying it.

    import { transfer } from 'react-iframe-kit';
    await remote.upload(transfer(buffer, [buffer]));
  • Debugging: debug: true on any hook or connectToParent logs every protocol message. In development each line starts with a summary, and a result names the call it answers and how long it took: react-iframe-kit ← result #q9x7c1 ok (getUser, 12 ms).

  • Redux DevTools: with the browser extension installed, connectReduxDevTools() from react-iframe-kit/devtools shows every message on the page as an action you can search, filter and inspect, whether or not debug is on. For your own tooling, onProtocolMessage(listener) gives you the same stream.

    import { connectReduxDevTools } from 'react-iframe-kit/devtools';
    if (import.meta.env.DEV) connectReduxDevTools();

A contract is only types: nothing checks, at runtime, that the other page sent what it says. Across origins that page may be buggy, out of date, or hostile, so validate arguments and payloads where it matters. react-iframe-kit/validate wraps a method or a handler with any Standard Schema library (Zod, Valibot, ArkType, …) (next release):

import { validateArgs, validatePayload } from 'react-iframe-kit/validate';
import { z } from 'zod';
const methods = {
// The schema checks the arguments as a tuple; the method gets its output.
prefill: validateArgs(z.tuple([z.object({ email: z.email(), promo: z.string().max(32) })]),
(data) => applyPrefill(data)),
};
widget.on('orderCompleted', validatePayload(Order, (order) => track(order)));
  • Invalid arguments reject the call with RIK_VALIDATION. The caller gets a RemoteError whose cause.code is 'RIK_VALIDATION' and whose cause.data lists the issues as { message, path }. The method isn’t called.
  • An invalid payload isn’t delivered; the error goes to reportError, where the console and error trackers see it, like an error thrown by a handler.
  • The method or handler receives the schema’s output, so transforms and defaults apply.
  • The entry is 0.7 kB and imports nothing else from the library; the schema library is yours.

Screen readers announce an iframe by its title. The page inside knows what it shows best, so it can send its document.title, and the parent passes it on:

// the page inside the iframe
connectToParent({ allowedOrigins: ['https://app.example.com'], syncTitle: true });
// the parent
const title = useIframeTitle(iframe);
return <iframe ref={setIframe} title={title ?? 'Checkout'} src={url} />;

The page opts in because a title can hold private data. useIframeTitle follows every change, and returns null until the title arrives, when it’s empty, and after the page unloads: keep a fallback.