There are many ways to approach a solution. My first thought was that you could split the actions into owner+URL subjects and then work with those:
const myEpic = (action$) => {
const completed$ = new Subject();
const flights = new DefaultMap((pair$) =>
pair$.exhaustMap((action) =>
fakeAjaxCall().map(() => ({
...action,
type: 'COMPLETED',
}))
)
.subscribe((action) => completed$.next(action))
);
action$.ofType('REMOTE_DATA_STARTED')
.subscribe((action) => {
flights.get(`${action.owner}+${action.url}`).next(action);
});
return completed$;
};
That works, but admittedly it requires maintaining some sort of "default map" where new owner+URL pairs get a new Subject
(I wrote a quick implementation). A test case that it passes:
test('myEpic does both drop actions and NOT drop actions for two owner+url pairs', async () => {
const arrayOfAtMost = (action$, limit) => action$.take(limit)
.timeoutWith(1000, Observable.empty())
.toArray().toPromise();
const action$ = new ActionsObservable(
Observable.create((observer) => {
// Jim #1 emits four (4) concurrent calls—we expect only two to be COMPLETED, one per URL
observer.next({ type: 'REMOTE_DATA_STARTED', url: 'google.com', owner: 'jim1' });
observer.next({ type: 'REMOTE_DATA_STARTED', url: 'google.com', owner: 'jim1' });
observer.next({ type: 'REMOTE_DATA_STARTED', url: 'google.org', owner: 'jim1' });
observer.next({ type: 'REMOTE_DATA_STARTED', url: 'google.org', owner: 'jim1' });
// Jim #2 emits two (2) calls at the same time as Jim #1—we expect only one to be COMPLETED, deduped URLs
observer.next({ type: 'REMOTE_DATA_STARTED', url: 'google.biz', owner: 'jim2' });
observer.next({ type: 'REMOTE_DATA_STARTED', url: 'google.biz', owner: 'jim2' });
// Once all of the above calls are completed, Jim #1 and Jim #2 make calls simultaneously
// We expect both to be COMPLETED
setTimeout(() => {
const url = 'https://stackoverflow.com/q/49563059/1267663';
observer.next({ type: 'REMOTE_DATA_STARTED', url, owner: 'jim1' });
observer.next({ type: 'REMOTE_DATA_STARTED', url, owner: 'jim2' });
}, 505);
})
);
const resultant$ = myEpic(action$);
const results = await arrayOfAtMost(resultant$, 5);
expect(results).toEqual([
{ type: 'COMPLETED', url: 'google.com', owner: 'jim1' },
{ type: 'COMPLETED', url: 'google.org', owner: 'jim1' },
{ type: 'COMPLETED', url: 'google.biz', owner: 'jim2' },
{ type: 'COMPLETED', url: 'https://stackoverflow.com/q/49563059/1267663', owner: 'jim1' },
{ type: 'COMPLETED', url: 'https://stackoverflow.com/q/49563059/1267663', owner: 'jim2' },
]);
});
The full solution, including the DefaultMap
implementation:
const { Observable, Subject } = require('rxjs');
class DefaultMap extends Map {
constructor(initializeValue) {
super();
this._initializeValue = initializeValue || (() => {});
}
get(key) {
if (this.has(key)) {
return super.get(key);
}
const subject = new Subject();
this._initializeValue(subject);
this.set(key, subject);
return subject;
}
}
const fakeAjaxCall = () => Observable.timer(500);
const myEpic = (action$) => {
const completed$ = new Subject();
const flights = new DefaultMap((uniquePair) =>
uniquePair.exhaustMap((action) =>
fakeAjaxCall().map(() => ({
...action,
type: 'COMPLETED',
}))
)
.subscribe((action) => completed$.next(action))
);
action$.ofType('REMOTE_DATA_STARTED')
.subscribe((action) => {
flights.get(`${action.owner}+${action.url}`).next(action);
});
return completed$;
};
* The above snippet isn't actually runnable, I just wanted it collapsible.
Runnable example with test cases
I've put together a runnable example with test cases on GitHub.
Using groupBy
and exhaustMap
operators
I wrote up the original solution with tests only to discover that, yes, it is possible with existing operators, the groupBy
and exhaustMap
you suggested:
const myEpic = action$ =>
action$.ofType('REMOTE_DATA_STARTED')
.groupBy((action) => `${action.owner}+${action.url}`)
.flatMap((pair$) =>
pair$.exhaustMap(action =>
fakeAjaxCall().map(() => ({
...action,
type: 'COMPLETED',
}))
)
);
Running that against the same test suite above will pass.