Commit 46b84d21 authored by Robert Knight's avatar Robert Knight

Use MessagePort `close` event if available

Use MessagePort's built-in `close` event to detect when the sending frame goes
away, instead of the sending window's `unload` event, which is being deprecated.

Currently only beta/dev versions of Chrome support the MessagePort `close`
event.

Fixes https://github.com/hypothesis/client/issues/5621
parent 90ca06a2
...@@ -50,6 +50,20 @@ type ResponseMessage = { ...@@ -50,6 +50,20 @@ type ResponseMessage = {
type Message = RequestMessage | ResponseMessage; type Message = RequestMessage | ResponseMessage;
function makeRequestMessage(
method: string,
args: unknown[] = [],
sequence = -1,
): RequestMessage {
return {
protocol: PROTOCOL,
version: VERSION,
arguments: args,
method,
sequence,
};
}
/** /**
* Send a PortRPC method call. * Send a PortRPC method call.
* *
...@@ -61,13 +75,7 @@ function sendCall( ...@@ -61,13 +75,7 @@ function sendCall(
args: unknown[] = [], args: unknown[] = [],
sequence = -1, sequence = -1,
) { ) {
port.postMessage({ port.postMessage(makeRequestMessage(method, args, sequence));
protocol: PROTOCOL,
version: VERSION,
arguments: args,
method,
sequence,
} satisfies RequestMessage);
} }
/** /**
...@@ -187,34 +195,49 @@ export class PortRPC<OnMethod extends string, CallMethod extends string> ...@@ -187,34 +195,49 @@ export class PortRPC<OnMethod extends string, CallMethod extends string>
constructor({ constructor({
userAgent = navigator.userAgent, userAgent = navigator.userAgent,
currentWindow = window, currentWindow = window,
}: { userAgent?: string; currentWindow?: Window } = {}) { forceUnloadListener = false,
}: {
userAgent?: string;
currentWindow?: Window;
// Test seam. Force the use of a Window "unload" listener even if the
// browser supports "close" events for MessagePort.
forceUnloadListener?: boolean;
} = {}) {
this._port = null; this._port = null;
this._methods = new Map(); this._methods = new Map();
this._sequence = 1; this._sequence = 1;
this._callbacks = new Map(); this._callbacks = new Map();
this._listeners = new ListenerCollection(); this._listeners = new ListenerCollection();
this._listeners.add(currentWindow, 'unload', () => {
if (this._port) { // In browsers that emit a "close" event when the other end of a MessagePort
// Send "close" notification directly. This works in Chrome, Firefox and // goes away, we can listen for that directly. In other browsers, we have to
// Safari >= 16. // send the "close" event through the message channel when the window
sendCall(this._port, 'close'); // containing the sending port is unloaded.
if (!('onclose' in MessagePort.prototype) || forceUnloadListener) {
// To work around a bug in Safari <= 15 which prevents sending messages this._listeners.add(currentWindow, 'unload', () => {
// while a window is unloading, try transferring the port to the parent frame if (this._port) {
// and re-sending the "close" event from there. // Send "close" notification directly. This works in Chrome, Firefox and
if ( // Safari >= 16.
currentWindow !== currentWindow.parent && sendCall(this._port, 'close');
shouldUseSafariWorkaround(userAgent)
) { // To work around a bug in Safari <= 15 which prevents sending messages
currentWindow.parent.postMessage( // while a window is unloading, try transferring the port to the parent frame
{ type: 'hypothesisPortClosed' }, // and re-sending the "close" event from there.
'*', if (
[this._port], currentWindow !== currentWindow.parent &&
); shouldUseSafariWorkaround(userAgent)
) {
currentWindow.parent.postMessage(
{ type: 'hypothesisPortClosed' },
'*',
[this._port],
);
}
} }
} });
}); }
this._pendingCalls = []; this._pendingCalls = [];
...@@ -249,6 +272,19 @@ export class PortRPC<OnMethod extends string, CallMethod extends string> ...@@ -249,6 +272,19 @@ export class PortRPC<OnMethod extends string, CallMethod extends string>
connect(port: MessagePort) { connect(port: MessagePort) {
this._port = port; this._port = port;
this._listeners.add(port, 'message', event => this._handle(event)); this._listeners.add(port, 'message', event => this._handle(event));
// For browsers that support a `close` event for MessagePort, we use that
// to identify when the other end disconnects. This is translated into a
// message event that is similar to what we receive in older browsers
// which use a Window unload handler instead.
this._listeners.add(port, 'close', () => {
port.dispatchEvent(
new MessageEvent('message', {
data: makeRequestMessage('close'),
}),
);
});
port.start(); port.start();
sendCall(port, 'connect'); sendCall(port, 'connect');
......
...@@ -243,7 +243,7 @@ describe('PortRPC', () => { ...@@ -243,7 +243,7 @@ describe('PortRPC', () => {
it('should send "close" event when window is unloaded', async () => { it('should send "close" event when window is unloaded', async () => {
const { port1, port2 } = new MessageChannel(); const { port1, port2 } = new MessageChannel();
const sender = new PortRPC(); const sender = new PortRPC({ forceUnloadListener: true });
const receiver = new PortRPC(); const receiver = new PortRPC();
const closeHandler = sinon.stub(); const closeHandler = sinon.stub();
...@@ -260,6 +260,26 @@ describe('PortRPC', () => { ...@@ -260,6 +260,26 @@ describe('PortRPC', () => {
assert.calledWith(closeHandler); assert.calledWith(closeHandler);
}); });
it('should send "close" event when MessagePort emits "close" event', async () => {
const { port1, port2 } = new MessageChannel();
const sender = new PortRPC();
const receiver = new PortRPC();
const closeHandler = sinon.stub();
receiver.on('close', closeHandler);
receiver.connect(port2);
sender.connect(port1);
await waitForMessageDelivery();
assert.notCalled(closeHandler);
const closed = waitForMessage(port2, 'close');
port2.dispatchEvent(new Event('close'));
await closed;
assert.calledOnce(closeHandler);
assert.calledWith(closeHandler);
});
it('should only invoke "close" handler once', async () => { it('should only invoke "close" handler once', async () => {
const { port1, port2 } = new MessageChannel(); const { port1, port2 } = new MessageChannel();
const sender = new PortRPC(); const sender = new PortRPC();
...@@ -322,6 +342,7 @@ describe('PortRPC', () => { ...@@ -322,6 +342,7 @@ describe('PortRPC', () => {
const sender = new PortRPC({ const sender = new PortRPC({
userAgent: safariUserAgent, userAgent: safariUserAgent,
currentWindow: childFrame.contentWindow, currentWindow: childFrame.contentWindow,
forceUnloadListener: true,
}); });
const receiver = new PortRPC(); const receiver = new PortRPC();
const closeHandler = sinon.stub(); const closeHandler = sinon.stub();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment