Skip to main content

useOpenedWindow

useOpenedWindow turns the messiest flow in web development — open a window on another domain, talk to it, and get a result back — into plain render state. Think checkout on shop.example.com opening a payment page on pay.example.com: the hook gives you a status to render, a typed post, and a result when the child finishes. The handshake, the message validation, the "user closed the window" detection: all handled.

import { openWindow, useOpenedWindow } from 'use-everywhere';

type ToPayment = { order: { orderId: string; amount: string } };
type FromPayment = { progress: { step: string } };
type Receipt = { receiptId: string; last4: string };

function PayButton() {
const pay = useOpenedWindow<ToPayment, FromPayment, Receipt>(() =>
openWindow('https://pay.example.com/checkout', {
peerOrigin: 'https://pay.example.com',
features: 'popup,width=440,height=640',
}),
);

if (pay.status === 'done') return <p>✓ receipt {pay.result!.receiptId}</p>;
if (pay.status === 'closed-early') return <p>Window closed — try again.</p>;
if (pay.status === 'error') return <p>Something broke: {String(pay.error)}</p>;

return (
<button onClick={pay.open} disabled={pay.status !== 'idle'}>
{pay.status === 'idle' ? 'Pay in secure window' : 'Waiting…'}
</button>
);
}

Signature

function useOpenedWindow<Out extends MessageMap, In extends MessageMap, R = unknown>(
factory: () => OpenedWindow<Out, In, R>,
): UseOpenedWindow<Out, In, R>;

The three type parameters are the contract with the child window: Out — messages you send it, In — messages it sends you, R — the one result it finishes with.

You pass a factory, not a window: nothing opens at render time. The factory runs when you call open() — from a click handler, because popup blockers require a user gesture.

openWindow options (what the factory calls)

OptionTypeDefaultWhat it does
peerOriginstringrequiredThe exact origin allowed to talk to you. '*' throws. The URL must belong to it too.
featuresstringPassed to window.open'popup,width=440,height=640' and friends.
readyTimeoutMsnumber15000How long to wait for the child to complete the handshake before failing with HandshakeTimeoutError.
allowAnyOriginbooleanfalseDev-only escape hatch, named to be scary. Never ship it.

(openFn and localWindow also exist as injection seams for tests — see Testing.)

Return value

FieldTypeNotes
open() => voidCall from a user gesture. Calling it again closes and replaces the previous window.
status'idle' | 'opening' | 'connected' | 'done' | 'closed-early' | 'error'The whole lifecycle as render state — see below.
resultR | undefinedThe child's finish() value, once status === 'done'.
errorunknownSet for 'closed-early' and 'error'.
post(type, payload) => voidTyped post to the child. Safe no-op while nothing is open; queued until the handshake completes.
close() => voidClose the child window yourself.

The status lifecycle

idle ──open()──► opening ──handshake──► connected ──finish()──► done
│ │
└──── window closed ────┴──► closed-early
└──── anything else ────────► error
  • opening — the window exists, the child page is still loading. Anything you post now is queued locally, not dropped.
  • connected — the handshake completed; queued messages flushed; the child's messages start arriving.
  • done — the child called finish(value); result holds the value.
  • closed-early — the user closed the window before finishing (WindowClosedError). Detected within ~400ms even if the child crashed.
  • error — anything else: handshake timeout, popup blocked, the child rejecting. error has the reason.

A stale window's outcome is ignored: if you open() a second time, whatever the first window does later cannot touch your state.

The other side of the conversation

The child page uses connectToOpener — core API, framework-free, same peerOrigin discipline:

on pay.example.com
import { connectToOpener } from '@use-everywhere/core';

const conn = connectToOpener<ToPayment, FromPayment, Receipt>({
peerOrigin: 'https://shop.example.com',
});
conn.on('order', (order) => renderOrderSummary(order));
conn.post('progress', { step: 'card-entered' });
conn.finish({ receiptId: 'r-123', last4: '4242' }); // resolves the opener's result

The full two-sided walkthrough lives in Cross-origin payments.

Gotchas

  • Call open() from a click handler. Popup blockers require a user gesture; if window.open returns null you land in 'error' with a clear message.
  • peerOrigin is not optional and '*' throws. This is the security model refusing to let you hold the foot-gun — every incoming message is checked against four gates (which ones?).
  • This is messaging, not shared state. State never crosses origins, on purpose: automatic merges from another security principal would be a confused-deputy bug. Explicit typed messages only.
  • Send data after 'connected', or just send. post queues while 'opening', so you can fire your order details immediately after open() and they'll arrive once the handshake lands.
  • A client-side lock is UX, not security. Pair the flow with server-side idempotency keys for anything involving money.

Where to next