Skip to content
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

Fix browser errors about async responses #304

Merged
merged 4 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ export class Store<S = any, A extends redux.Action = redux.AnyAction> {
type WrapStore<S, A extends redux.Action = redux.AnyAction> = (
store: redux.Store<S, A>,
configuration?: {
channelName?: string;
dispatchResponder?(
dispatchResult: any,
send: (response: any) => void
Expand All @@ -106,7 +105,9 @@ type WrapStore<S, A extends redux.Action = redux.AnyAction> = (
export function createWrapStore<
S,
A extends redux.Action = redux.AnyAction
>(): WrapStore<S, A>;
>(configuration?: {
channelName?: string;
}): WrapStore<S, A>;

export function alias(aliases: {
[key: string]: (action: any) => any;
Expand Down
28 changes: 27 additions & 1 deletion src/listener.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
export const createDeferredListener = () => {
/**
* Returns a function that can be passed as a listener callback to a browser
* API. The listener will queue events until setListener is called.
*
* @param {Function} filter - A function that filters messages to be handled by
* the listener. This is important to avoid telling the browser to expect an
* async response when the message is not intended for this listener.
*
* @example
* const filter = (message, sender, sendResponse) => {
* return message.type === "my_type"
* }
*
* const { listener, setListener } = createDeferredListener(filter);
* chrome.runtime.onMessage.addListener(listener);
*
* // Later, define the listener to handle messages. Messages received
* // before this point are queued.
* setListener((message, sender, sendResponse) => {
* console.log(message);
* });
*/
export const createDeferredListener = (filter) => {
let resolve = () => {};
const fnPromise = new Promise((resolve_) => (resolve = resolve_));

const listener = (message, sender, sendResponse) => {
if (!filter(message, sender, sendResponse)) {
return;
}

fnPromise.then((fn) => {
fn(message, sender, sendResponse);
});
Expand Down
70 changes: 31 additions & 39 deletions src/wrap-store/wrapStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ const defaultOpts = {
* @typedef {function} WrapStore
* @param {Object} store A Redux store
* @param {Object} options
* @param {string} options.channelName The name of the channel for this store.
* @param {function} options.dispatchResponder A function that takes the result
* of a store dispatch and optionally implements custom logic for responding to
* the original dispatch message.
Expand All @@ -61,33 +60,37 @@ const defaultOpts = {
* Wraps a Redux store so that proxy stores can connect to it. This function
* must be called synchronously when the extension loads to avoid dropping
* messages that woke the service worker.
* @param {Object} options
* @param {string} options.channelName The name of the channel for this store.
* @return {WrapStore} The wrapStore function that accepts a Redux store and
* options. See {@link WrapStore}.
*/
export default () => {
export default ({ channelName = defaultOpts.channelName } = defaultOpts) => {
const browserAPI = getBrowserAPI();

const filterStateMessages = (message) =>
message.type === FETCH_STATE_TYPE && message.channelName === channelName;

const filterActionMessages = (message) =>
message.type === DISPATCH_TYPE && message.channelName === channelName;

// Setup message listeners synchronously to avoid dropping messages if the
// extension is woken by a message.
const stateProviderListener = createDeferredListener();
const actionListener = createDeferredListener();
const stateProviderListener = createDeferredListener(filterStateMessages);
const actionListener = createDeferredListener(filterActionMessages);

browserAPI.runtime.onMessage.addListener(stateProviderListener.listener);
browserAPI.runtime.onMessage.addListener(actionListener.listener);

return (
store,
{
channelName = defaultOpts.channelName,
dispatchResponder = defaultOpts.dispatchResponder,
serializer = defaultOpts.serializer,
deserializer = defaultOpts.deserializer,
diffStrategy = defaultOpts.diffStrategy,
} = defaultOpts
) => {
if (!channelName) {
throw new Error("channelName is required in options");
}
if (typeof serializer !== "function") {
throw new Error("serializer must be a function");
}
Expand All @@ -104,26 +107,21 @@ export default () => {
* Respond to dispatches from UI components
*/
const dispatchResponse = (request, sender, sendResponse) => {
if (
request.type === DISPATCH_TYPE &&
request.channelName === channelName
) {
const action = Object.assign({}, request.payload, {
_sender: sender,
});

let dispatchResult = null;
// Only called with messages that pass the filterActionMessages filter.
const action = Object.assign({}, request.payload, {
_sender: sender,
});

try {
dispatchResult = store.dispatch(action);
} catch (e) {
dispatchResult = Promise.reject(e.message);
console.error(e);
}
let dispatchResult = null;

dispatchResponder(dispatchResult, sendResponse);
return true;
try {
dispatchResult = store.dispatch(action);
} catch (e) {
dispatchResult = Promise.reject(e.message);
console.error(e);
}

dispatchResponder(dispatchResult, sendResponse);
};

/**
Expand Down Expand Up @@ -173,33 +171,27 @@ export default () => {
channelName, // Notifying what store is broadcasting the state changes
});

const withPayloadDeserializer = withDeserializer(deserializer);
const shouldDeserialize = (request) =>
request.type === DISPATCH_TYPE && request.channelName === channelName;

/**
* State provider for content-script initialization
*/
stateProviderListener.setListener((request, sender, sendResponse) => {
// This listener is only called with messages that pass filterStateMessages
const state = store.getState();

if (
request.type === FETCH_STATE_TYPE &&
request.channelName === channelName
) {
sendResponse({
type: FETCH_STATE_TYPE,
payload: state,
});
}
sendResponse({
type: FETCH_STATE_TYPE,
payload: state,
});
});

/**
* Setup action handler
*/
const withPayloadDeserializer = withDeserializer(deserializer);

withPayloadDeserializer(actionListener.setListener)(
dispatchResponse,
shouldDeserialize
filterActionMessages
);
};
};
32 changes: 31 additions & 1 deletion test/listener.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import sinon from "sinon";
import { createDeferredListener } from "../src/listener";
import should from "should";

const filterAny = () => {
return true;
};

describe("createDeferredListener", () => {
it("queues calls to the listener", async () => {
const { setListener, listener } = createDeferredListener();
const { setListener, listener } = createDeferredListener(filterAny);
const spy = sinon.spy();

// Trigger a couple of events
Expand All @@ -17,6 +22,7 @@ describe("createDeferredListener", () => {
listener("message3", "sender3", "sendResponse3");
listener("message4", "sender4", "sendResponse4");

// Wait for promise queue to clear
await Promise.resolve();

spy.callCount.should.equal(4);
Expand All @@ -25,4 +31,28 @@ describe("createDeferredListener", () => {
spy.getCall(2).args.should.eql(["message3", "sender3", "sendResponse3"]);
spy.getCall(3).args.should.eql(["message4", "sender4", "sendResponse4"]);
});

it("ignores messages that don't pass the filter", async () => {
const filter = (message) => {
return message === "message";
};

const { setListener, listener } = createDeferredListener(filter);
const spy = sinon.spy();

const result1 = listener("message", "sender", "sendResponse");
const result2 = listener("message2", "sender2", "sendResponse2");

result1.should.eql(true);
console.log(result2);
should(result2).eql(undefined);

setListener(spy);

// Wait for promise queue to clear
await Promise.resolve();

spy.callCount.should.equal(1);
spy.getCall(0).args.should.eql(["message", "sender", "sendResponse"]);
});
});
40 changes: 20 additions & 20 deletions test/wrapStore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ describe('wrapStore', function () {
});

it('should dispatch actions received on onMessage to store', async function () {
const wrapStore = createWrapStore();
const wrapStore = createWrapStore({ channelName });

wrapStore(store, { channelName });
wrapStore(store);
listeners.onMessage.forEach(l => l(message, sender, callback));

await Promise.resolve();
Expand All @@ -106,9 +106,9 @@ describe('wrapStore', function () {
});

it('should not dispatch actions received on onMessage for other ports', function () {
const wrapStore = createWrapStore();
const wrapStore = createWrapStore({ channelName });

wrapStore(store, { channelName });
wrapStore(store);
message.channelName = channelName + '2';
listeners.onMessage.forEach(l => l(message, sender, callback));

Expand All @@ -117,9 +117,9 @@ describe('wrapStore', function () {

it('should deserialize incoming messages correctly', async function () {
const deserializer = sinon.spy(JSON.parse);
const wrapStore = createWrapStore();
const wrapStore = createWrapStore({ channelName });

wrapStore(store, { channelName, deserializer });
wrapStore(store, { deserializer });
message.payload = JSON.stringify(payload);
listeners.onMessage.forEach(l => l(message, sender, callback));

Expand All @@ -137,9 +137,9 @@ describe('wrapStore', function () {

it('should not deserialize incoming messages for other ports', function () {
const deserializer = sinon.spy(JSON.parse);
const wrapStore = createWrapStore();
const wrapStore = createWrapStore({ channelName });

wrapStore(store, { channelName, deserializer });
wrapStore(store, { deserializer });
message.channelName = channelName + '2';
message.payload = JSON.stringify(payload);
listeners.onMessage.forEach(l => l(message, sender, callback));
Expand Down Expand Up @@ -172,9 +172,9 @@ describe('wrapStore', function () {
.onThirdCall().returns(secondState);

const serializer = (payload) => JSON.stringify(payload);
const wrapStore = createWrapStore();
const wrapStore = createWrapStore({ channelName });

wrapStore(store, { channelName, serializer });
wrapStore(store, { serializer });

// Simulate a state update by calling subscribers
subscribers.forEach(subscriber => subscriber());
Expand Down Expand Up @@ -223,9 +223,9 @@ describe('wrapStore', function () {
type: 'FAKE_DIFF',
oldObj, newObj
}]);
const wrapStore = createWrapStore();
const wrapStore = createWrapStore({ channelName });

wrapStore(store, { channelName, diffStrategy });
wrapStore(store, { diffStrategy });

// Simulate a state update by calling subscribers
subscribers.forEach(subscriber => subscriber());
Expand Down Expand Up @@ -259,25 +259,25 @@ describe('wrapStore', function () {

it('should throw an error if serializer is not a function', function () {
should.throws(() => {
const wrapStore = createWrapStore();
const wrapStore = createWrapStore({ channelName });

wrapStore(store, { channelName, serializer: "abc" });
wrapStore(store, { serializer: "abc" });
}, Error);
});

it('should throw an error if deserializer is not a function', function () {
should.throws(() => {
const wrapStore = createWrapStore();
const wrapStore = createWrapStore({ channelName });

wrapStore(store, { channelName, deserializer: "abc" });
wrapStore(store, { deserializer: "abc" });
}, Error);
});

it('should throw an error if diffStrategy is not a function', function () {
should.throws(() => {
const wrapStore = createWrapStore();
const wrapStore = createWrapStore({ channelName });

wrapStore(store, { channelName, diffStrategy: "abc" });
wrapStore(store, { diffStrategy: "abc" });
}, Error);
});
});
Expand Down Expand Up @@ -314,9 +314,9 @@ describe('wrapStore', function () {
}
}
};
const wrapStore = createWrapStore();
const wrapStore = createWrapStore({ channelName });

wrapStore(store, { channelName });
wrapStore(store);

tabResponders.length.should.equal(5);
},
Expand Down
Loading