-
Notifications
You must be signed in to change notification settings - Fork 198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support MessageChannel in bridge.js
#3566
Conversation
Codecov Report
@@ Coverage Diff @@
## master #3566 +/- ##
==========================================
- Coverage 98.62% 98.60% -0.03%
==========================================
Files 211 211
Lines 7723 7738 +15
Branches 1756 1758 +2
==========================================
+ Hits 7617 7630 +13
- Misses 106 108 +2
Continue to review full report at Codecov.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've had a first look through this and left some feedback. Here are my high-level thoughts:
- The code modernization/simplification and JSDoc improvements in
shared/bridge.js
andannotator/cross-frame.js
looks great. It might be a good idea to split those changes off into a separate smaller PR which can be landed first. - The
Bridge
class provides two separate ways to create a channel that works with aMessagePort
and there is some duplication between the code paths create a channel for a Window vs a MessagePort. I would prefer that there is only one public API to create aBridge
for aMessagePort
and to avoid the duplication, since the only thing that changes depending on the message source is theRPC
constructor arguments. - I suggested to omit the frame-type argument to
createChannelFromPort
for the moment, because when the time comes that we want to target messages at specific frames, we might want to revisit the use ofBridge
altogether. The main function ofBridge
is to send messages to multiple frames, so perhaps we don't need it at all if we're targeting a specific frame. - The existing
Bridge
tests are unit tests, although they don't use$imports.$mock
like we do in more recent code, but the new ones are integration tests. Mixing and matching unit and integration tests in the same module creates confusion for subsequent developers because it becomes unclear which approach they should use for new tests. While I think we will want some integration tests for the inter-frame communication, theRPC
interface is simple enough that I think we can mock it inbridge-test.js
, but using$imports.$mock
. It might be a good idea to create a separate PR that modernizes the mocking ofRPC
inbridge-test.js
before making the functional change to support MessagePort.
src/shared/bridge.js
Outdated
@@ -153,6 +189,9 @@ export default class Bridge { | |||
/** | |||
* Unregister any callbacks registered with `on`. | |||
* | |||
* Attention: for this to have the intended effect, it needs to be called | |||
* before `createChannel`, because at that point the methods are registered | |||
* with the `RPC` class and there is no way to remove the listeners. | |||
* @param {string} method | |||
*/ | |||
off(method) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't look like we ever call this method anywhere. On that basis we could just remove it and the associated tests. This would remove the need for the caveat above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree 100%.
I also would suggest to rename on(...)
to register(...)
. It makes clear that you have to:
register
the method- create the channel
call
the method
src/annotator/cross-frame.js
Outdated
@@ -89,7 +89,7 @@ export class CrossFrame { | |||
* Subscribe to an event from the sidebar. | |||
* | |||
* @param {string} event | |||
* @param {Function} callback | |||
* @param {(...args: any[]) => void} callback |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These functions can pass a result callback as the last argument. Unfortunately I don't think there is a way to specify that at present (see microsoft/TypeScript#1360). This will have to do for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I took a look too, but iI didn't find an easy way to add types to the arguments and callback.
However, an approach that in my opinion is better is to change the signature of the call
method in FrameRPC
and CrossFrame
to allow for an object:
@@ -110,17 +110,18 @@ export class RPC {
* If the final argument in `args` is a function, it is treated as a callback
* which is invoked with the response.
*
- * @param {string} method
- * @param {any[]} args
+ * @param {object} options
+ * @param {string} options.method
+ * @param {any[]} options.args
+ * @param {(...args: any[]) => void} [options.callback]
*/
- call(method, ...args) {
+ call({ method, args, callback }) {
if (this._destroyed) {
return;
}
const seq = this._sequence++;
- if (typeof args[args.length - 1] === 'function') {
- this._callbacks[seq] = args[args.length - 1];
- args = args.slice(0, -1);
+ if (callback) {
+ this._callbacks[seq] = callback;
}
this.destFrame.postMessage(
{
If would make the use of it a bit more verbose, but also more explicit:
- bridge.call('sumMethod', 1, 2, 3, total => ....)`
+ bridge.call({method: 'sumMethod', args: [1, 2, 3], callback: total => ....})`
Alternatively, this other variant may require a little bit less work refactoring old code:
- bridge.call('sumMethod', 1, 2, 3, total => ....)`
+ bridge.call('sumMethod', {args: [1, 2, 3], callback: total => ....})`
src/shared/bridge.js
Outdated
@@ -10,8 +10,11 @@ import { RPC } from './frame-rpc'; | |||
*/ | |||
export default class Bridge { | |||
constructor() { | |||
/** @type {Array<RPC>} */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/** @type {Array<RPC>} */ | |
/** @type {RPC[]} */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was debating about that, but I thought Array
was more consistent with the onConnectListeners
's type two lines after:
/** @type {Array<(...args: any[]) => void>} */
this.onConnectListeners = [];
@@ -21,7 +24,7 @@ export default class Bridge { | |||
* This removes the event listeners for messages arriving from other windows. | |||
*/ | |||
destroy() { | |||
Array.from(this.links).map(link => link.channel.destroy()); | |||
this.links.forEach(channel => channel.destroy()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Array.from
usage here was probably a hangover from semi-automated CoffeeScript => JS conversion. The decaffeinate tool that we used for some of this conversion added Array.from
in some places to be conservative.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I makes sense.
src/shared/bridge.js
Outdated
} | ||
|
||
/** | ||
* Create a communication channel using `MessageChannel.MessagePort`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
* Create a communication channel using `MessageChannel.MessagePort`. | |
* Create a communication channel using a `MessagePort` |
The previous wording suggests to me that MessagePort
is a property of MessageChannel
, but it is its own global.
src/shared/test/bridge-test.js
Outdated
}); | ||
clock.tick(1000); | ||
} finally { | ||
clock.restore(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As this test is testing what happens in the event of a timeout I expected to see an error occur. I had to go back and look at the Bridge
code to see that a null
result indicates that a timeout occurred. This is surprising API design IMO, although I can see it is not new behavior on this branch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with you, it is surprising. Like you, I asked myself why not to reject the promise instead of returning a [null]
. Rejecting the promise causes the channel to be closed, which is also surprising.
src/shared/test/bridge-test.js
Outdated
try { | ||
await reciprocalBridge.call('method1', 'params1'); | ||
} catch (err) { | ||
assert.equal(err, error); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
try { | |
await reciprocalBridge.call('method1', 'params1'); | |
} catch (err) { | |
assert.equal(err, error); | |
} | |
let err; | |
try { | |
await reciprocalBridge.call('method1', 'params1'); | |
} catch (err) { | |
err = e; | |
} | |
assert.equal(err, <expected error>) |
If the assert
is inside the catch
then the test can incorrectly pass if the code fails to throw an error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point.
src/shared/test/bridge-test.js
Outdated
try { | ||
reciprocalBridge.destroy(); | ||
|
||
// It timeouts using bridge |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like a rather roundabout way of testing that the channels were destroyed. The existing #destroy
test just checks that the destroy
method was called on the channel.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it is a roundabout, but it is exactly what a user of the Bridge
API will expect, a timeout after 1s resulting on a [null]
to be returned.
@@ -10,246 +10,476 @@ describe('shared/bridge', () => { | |||
beforeEach(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The existing Bridge
tests are unit tests that stub RPC.call
. The new tests are integration tests that end up testing a lot of RPC
-specific details. We generally try to avoid mixing unit and integration tests in the same module, as becomes unclear for developers making changes later which approach they should follow when adding a new test. Sometimes there might be cases where there is justification to have a test that deviates from the other tests in terms of its unit-ness. In that case it is good to call out the fact that these are an exception. For example with a specific "integration tests" describe
block.
At the time when the Bridge
tests were originally written I don't think we had a good mocking tool ($imports.$mock
) which is probably why they create a real RPC
and then stub call
each time. This could be simplified to use $imports.$mock
now. eg:
// Active channels
let channels = [];
class FakeRPC {
constructor(sourceFrame, ...) {
this.sourceFrame = sourceFrame;
...
this.call = sinon.stub().resolves(...)
channels.push(this);
}
destroy() {
channels.splice(channels.indexOf(this), 1);
}
}
...
$imports.$mock({ './frame-rpc': { RPC: FakeRPC }})
I think we are eventually going to want some integration tests for inter-frame messaging. Following the examples of src/{annotator, sidebar}/test/integration
these could go in src/shared/test/integration/messaging-test.js
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is good to know about the integration tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Until this #3565, FrameRPC
didn't have unit testing. I think the Bridge
was supposed to unit test FrameRPC
, however, because of the difficulty of creating two frames to communicate with each other, most of the methods in FrameRPC
were mocked.
src/shared/bridge.js
Outdated
* @param {string} origin - The origin of the document in `source`. | ||
* @param {string} token | ||
* @return {RPC} - Channel for communicating with the window. | ||
* | ||
* @deprecated | ||
*/ | ||
createChannel(source, origin, token) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are currently two different public APIs for creating a channel from a MessagePort
- calling createChannel
or calling createChannelFromPort
directly. This seems redundant to me. I can suggest a few different ways to rectify this:
- Package up the data needed to create a channel for a window into one object, then make
createChannel
accept either that object or aMessagePort
, and only havecreateChannel
as the public API - Have two separate public methods,
createChannelForWindow
andcreateChannelForPort
, which work with a Window and MessagePort respectively. Internally these could delegate to a shared helper to do most of the work
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I can do something along those lines. The two implementations are 95% similar, that's why I thought it was OK to have both of them, since one of them is marked @deprecated
and will be removed.
f6ae496
to
a6bb101
Compare
8886f60
to
32dbb68
Compare
Initially, I tried to if/else portions of the code to accommodate for `MessagePort`, aiming to avoid duplication of the code (like I did for `shared/frame-rpc.js` #3565). However, I found that, unlike `shared/frame-rpc.js`, this resulted into a spaghetti-type of code, not very understandable. Then, I decided to create two internal methods to support both the current communication using `Window` and the new `MessagePort`. This in my opinion leads to a clearer results, although some code is duplicated in both methods. This PR will result on a reduction in code coverage, which will be fix by #3590.
32dbb68
to
6dad72c
Compare
The need for this "testing only" code can be removed by improving the way that we simulate the `connect` message being handled in tests.
This argument was not used by any callers of `Bridge#onConnect` and removing it eliminates a difference between creating a channel that uses `Window.postMessage` vs one that uses `MessagePort`.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The revised PR looks better. The main feedback I have is regarding this comment:
This PR will result on a reduction in code coverage, which will be fix by #3598.
PR #3598 adds an integration test, but I don't see that as a reason not to have unit tests as well. Put another way, whether you run the integration tests or not shouldn't affect code coverage for most modules, with some rare exceptions.
Since you're away for the rest of this week, I've gone ahead and pushed a few additional unit tests as well as a couple of other minor changes based on my comments here. I will get this landed first thing tomorrow.
src/shared/bridge.js
Outdated
@@ -27,18 +27,83 @@ export default class Bridge { | |||
this.links.forEach(channel => channel.destroy()); | |||
} | |||
|
|||
/** | |||
* Deprecated - Remove after MessagePort conversion | |||
* @typedef windowOptions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
* @typedef windowOptions | |
* @typedef WindowOptions |
Type names should use PascalCase. I'd probably have gone for something without an Options
suffix since that tends to imply optional parameters. In this case the object is really a combination of a Window
+ origin. Maybe something like WindowTarget
(as in fields needed to send messages to a particular window target)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I pushed a commit that keeps the name but just converts it to PascalCase.
/** | ||
* Deprecated - Remove after MessagePort conversion | ||
* @typedef windowOptions | ||
* @prop {Window} source - The source window |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think using the term source
is potentially a little confusing here because this window is both a source of messages that trigger on
callbacks and a target of messages sent with call
. Also when the RPC
channel is constructed, the source
is passed as the destFrameOrPort
argument.
Naming this target
would be misleading for the same reason and naming it window
could cause it to be confused with the window
global. So perhaps just revising the documentation to something like @prop {Window} source - The window to communicate with
would be enough for now.
*/ | ||
createChannel(options) { | ||
if (options instanceof MessagePort) { | ||
return this._createChannelForPort(options); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I pushed a commit that adds a few extra tests to cover the new code paths.
src/shared/bridge.js
Outdated
@@ -53,7 +118,7 @@ export default class Bridge { | |||
const connect = (_token, cb) => { | |||
if (_token === token) { | |||
cb(); | |||
ready(); | |||
ready(); // This is necessary for testing only. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This raised eyebrows. Why do we have testing-only code in this callback? Since there is a callback being invoked here it seems to me that the tests should try to leverage that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I pushed a commit that reworks the tests to avoid the need for the ready()
callback here. This means that the MessagePort and Window code paths are now the same except that the Window code path checks the token before invoking the callback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I left the code as it was originally (since it will be removed anyway), but I added the comment, because like you said, it didn't look right.
src/shared/bridge.js
Outdated
@@ -162,7 +227,7 @@ export default class Bridge { | |||
/** | |||
* Add a listener to be called upon a new connection. | |||
* | |||
* @param {(channel: RPC, window: Window) => void} listener | |||
* @param {(channel: RPC, window?: Window) => void} listener |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at the callers of onConnect
, none of them make use of the window
argument, so we can just drop it.
- The
Guest
class invokesonConnect
indirectly (viaCrossFrame
) with no arguments - The
FrameSync
service invokesonConnect
but only uses the first argument
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I pushed a commit that removes the window
argument.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍🏼
Initially, I tried to if/else portions of the code to accommodate for
MessagePort
, aiming to avoid duplication of the code (like I did forshared/frame-rpc.js
#3565). However, I found that, unlikeshared/frame-rpc.js
, this resulted into a spaghetti-type of code, notvery understandable.
Then, I followed @robertknight suggestion:
Package up the data needed to create a channel for a window into one object, then make createChannel accept either that object or a MessagePort, and only have createChannel as the public API
Have two separate public methods, createChannelForWindow and createChannelForPort, which work with a Window and MessagePort respectively. Internally these could delegate to a shared helper to do most of the work
The two internal methods to support the current communication using
Window
and the newMessagePort
, leads to clearer results, although some code is duplicated in both methods.This PR will result on a reduction in code coverage, which will be fix
by #3598. (Edit: Additional unit tests have been added in this PR, so this comment no longer applies - @robertknight)