@stream-io/video-client 1.55.2 → 1.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/index.browser.es.js +180 -103
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +180 -103
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +180 -103
- package/dist/index.es.js.map +1 -1
- package/dist/src/helpers/BlockedAudioTracker.d.ts +16 -1
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
- package/dist/src/rtc/Publisher.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +9 -2
- package/dist/src/store/rxUtils.d.ts +5 -0
- package/package.json +2 -2
- package/src/Call.ts +1 -0
- package/src/helpers/BlockedAudioTracker.ts +71 -13
- package/src/helpers/DynascaleManager.ts +14 -3
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +54 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +45 -0
- package/src/reporting/ClientEventReporter.ts +37 -31
- package/src/reporting/__tests__/ClientEventReporter.test.ts +159 -44
- package/src/rtc/BasePeerConnection.ts +1 -0
- package/src/rtc/Publisher.ts +78 -58
- package/src/rtc/__tests__/Publisher.test.ts +297 -0
- package/src/rtc/types.ts +13 -11
- package/src/store/CallState.ts +5 -17
- package/src/store/rxUtils.ts +15 -0
|
@@ -24,6 +24,7 @@ import { IceTrickleBuffer } from '../IceTrickleBuffer';
|
|
|
24
24
|
import { StreamClient } from '../../coordinator/connection/client';
|
|
25
25
|
import { TransceiverCache } from '../TransceiverCache';
|
|
26
26
|
import { promiseWithResolvers } from '../../helpers/promise';
|
|
27
|
+
import { settled } from '../../helpers/concurrency';
|
|
27
28
|
import { isFirefox } from '../../helpers/browsers';
|
|
28
29
|
|
|
29
30
|
vi.mock('../../StreamSfuClient', () => {
|
|
@@ -1318,6 +1319,97 @@ describe('Publisher', () => {
|
|
|
1318
1319
|
});
|
|
1319
1320
|
});
|
|
1320
1321
|
|
|
1322
|
+
describe('mid assignment during negotiation', () => {
|
|
1323
|
+
const sdpWith = (
|
|
1324
|
+
sections: Array<{ mid: string; trackId?: string; port: number }>,
|
|
1325
|
+
) =>
|
|
1326
|
+
[
|
|
1327
|
+
'v=0',
|
|
1328
|
+
'o=- 0 0 IN IP4 127.0.0.1',
|
|
1329
|
+
's=-',
|
|
1330
|
+
't=0 0',
|
|
1331
|
+
...sections.flatMap((s) => [
|
|
1332
|
+
`m=video ${s.port} UDP/TLS/RTP/SAVPF 96`,
|
|
1333
|
+
'c=IN IP4 0.0.0.0',
|
|
1334
|
+
`a=mid:${s.mid}`,
|
|
1335
|
+
...(s.trackId
|
|
1336
|
+
? [`a=msid:stream-id ${s.trackId}`, 'a=sendonly']
|
|
1337
|
+
: ['a=inactive']),
|
|
1338
|
+
]),
|
|
1339
|
+
'',
|
|
1340
|
+
].join('\r\n');
|
|
1341
|
+
|
|
1342
|
+
it('announces the mid the browser assigned, not the one guessed from the pending offer', async () => {
|
|
1343
|
+
const track = new MediaStreamTrack();
|
|
1344
|
+
const transceiver = new RTCRtpTransceiver();
|
|
1345
|
+
// @ts-expect-error test setup
|
|
1346
|
+
transceiver.sender.track = track;
|
|
1347
|
+
// @ts-expect-error readonly field
|
|
1348
|
+
transceiver.mid = null;
|
|
1349
|
+
|
|
1350
|
+
publisher['transceiverCache'].add({
|
|
1351
|
+
publishOption: publisher['publishOptions'][0],
|
|
1352
|
+
transceiver,
|
|
1353
|
+
options: {},
|
|
1354
|
+
negotiated: false,
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
const firstOffer = {
|
|
1358
|
+
type: 'offer',
|
|
1359
|
+
sdp: sdpWith([{ mid: '0', trackId: track.id, port: 9 }]),
|
|
1360
|
+
};
|
|
1361
|
+
const secondOffer = {
|
|
1362
|
+
type: 'offer',
|
|
1363
|
+
sdp: sdpWith([
|
|
1364
|
+
{ mid: '0', port: 0 },
|
|
1365
|
+
{ mid: '1', trackId: track.id, port: 9 },
|
|
1366
|
+
]),
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
const pc = publisher['pc'];
|
|
1370
|
+
vi.spyOn(pc, 'createOffer')
|
|
1371
|
+
// @ts-expect-error TS picks up the wrong overload
|
|
1372
|
+
.mockResolvedValueOnce(firstOffer)
|
|
1373
|
+
// @ts-expect-error TS picks up the wrong overload
|
|
1374
|
+
.mockResolvedValueOnce(secondOffer);
|
|
1375
|
+
|
|
1376
|
+
vi.spyOn(pc, 'setLocalDescription').mockImplementation(async (desc) => {
|
|
1377
|
+
// @ts-expect-error readonly field
|
|
1378
|
+
transceiver.mid =
|
|
1379
|
+
desc?.sdp === firstOffer.sdp
|
|
1380
|
+
? '0'
|
|
1381
|
+
: desc?.sdp === secondOffer.sdp
|
|
1382
|
+
? '1'
|
|
1383
|
+
: null; // rollback reverts the mid of a never-negotiated transceiver
|
|
1384
|
+
// @ts-expect-error readonly field
|
|
1385
|
+
pc.signalingState =
|
|
1386
|
+
desc?.type === 'rollback' ? 'stable' : 'have-local-offer';
|
|
1387
|
+
});
|
|
1388
|
+
vi.spyOn(pc, 'setRemoteDescription').mockResolvedValue();
|
|
1389
|
+
|
|
1390
|
+
sfuClient.setPublisher = vi
|
|
1391
|
+
.fn()
|
|
1392
|
+
.mockRejectedValueOnce(new Error('SetPublisherTimeout'));
|
|
1393
|
+
await expect(publisher['negotiate']()).rejects.toThrow(
|
|
1394
|
+
'SetPublisherTimeout',
|
|
1395
|
+
);
|
|
1396
|
+
expect(pc.setLocalDescription).toHaveBeenLastCalledWith({
|
|
1397
|
+
type: 'rollback',
|
|
1398
|
+
});
|
|
1399
|
+
expect(transceiver.mid).toBeNull();
|
|
1400
|
+
|
|
1401
|
+
sfuClient.setPublisher = vi
|
|
1402
|
+
.fn()
|
|
1403
|
+
.mockResolvedValue({ response: { sdp: 'answer-sdp' } });
|
|
1404
|
+
await publisher['negotiate']();
|
|
1405
|
+
|
|
1406
|
+
expect(sfuClient.setPublisher).toHaveBeenCalledWith({
|
|
1407
|
+
sdp: secondOffer.sdp,
|
|
1408
|
+
tracks: [expect.objectContaining({ trackId: track.id, mid: '1' })],
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1321
1413
|
describe('Firefox unpublish workaround', () => {
|
|
1322
1414
|
const mockSenderParams = (
|
|
1323
1415
|
transceiver: RTCRtpTransceiver,
|
|
@@ -1845,4 +1937,209 @@ describe('Publisher', () => {
|
|
|
1845
1937
|
expect(trackStopSpy).toHaveBeenCalled();
|
|
1846
1938
|
});
|
|
1847
1939
|
});
|
|
1940
|
+
|
|
1941
|
+
describe('concurrent transceiver management', () => {
|
|
1942
|
+
const videoOption = (id: number, codecName: string): PublishOption =>
|
|
1943
|
+
fromPartial({
|
|
1944
|
+
id,
|
|
1945
|
+
trackType: TrackType.VIDEO,
|
|
1946
|
+
bitrate: 1000,
|
|
1947
|
+
codec: { name: codecName },
|
|
1948
|
+
fps: 30,
|
|
1949
|
+
maxTemporalLayers: 3,
|
|
1950
|
+
maxSpatialLayers: 3,
|
|
1951
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
const makeTrack = (): MediaStreamTrack => {
|
|
1955
|
+
const track = new MediaStreamTrack();
|
|
1956
|
+
// @ts-expect-error readonly field
|
|
1957
|
+
track.id = crypto.randomUUID();
|
|
1958
|
+
vi.spyOn(track, 'clone').mockImplementation(() => makeTrack());
|
|
1959
|
+
return track;
|
|
1960
|
+
};
|
|
1961
|
+
|
|
1962
|
+
const makeTransceiver = (track: MediaStreamTrack) => {
|
|
1963
|
+
const transceiver = new RTCRtpTransceiver();
|
|
1964
|
+
// @ts-expect-error test setup
|
|
1965
|
+
transceiver.sender.track = track;
|
|
1966
|
+
transceiver.sender.replaceTrack = vi.fn(async (next) => {
|
|
1967
|
+
// @ts-expect-error test setup
|
|
1968
|
+
transceiver.sender.track = next;
|
|
1969
|
+
});
|
|
1970
|
+
return transceiver;
|
|
1971
|
+
};
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* The shared mock returns the same transceiver for every call, which would
|
|
1975
|
+
* hide duplicates. Give every `addTransceiver` call its own transceiver,
|
|
1976
|
+
* with the track bound to the sender like the browser does.
|
|
1977
|
+
*/
|
|
1978
|
+
const withDistinctTransceivers = (
|
|
1979
|
+
decorate: (transceiver: RTCRtpTransceiver) => void = () => {},
|
|
1980
|
+
) => {
|
|
1981
|
+
vi.spyOn(publisher['pc'], 'addTransceiver').mockImplementation(
|
|
1982
|
+
// @ts-expect-error the mock only implements what the Publisher uses
|
|
1983
|
+
(track: MediaStreamTrack) => {
|
|
1984
|
+
const transceiver = makeTransceiver(track);
|
|
1985
|
+
decorate(transceiver);
|
|
1986
|
+
return transceiver;
|
|
1987
|
+
},
|
|
1988
|
+
);
|
|
1989
|
+
};
|
|
1990
|
+
|
|
1991
|
+
const announcedKeys = () =>
|
|
1992
|
+
publisher
|
|
1993
|
+
.getAnnouncedTracks(undefined)
|
|
1994
|
+
.map((track) => `${track.trackType}:${track.publishOptionId}`);
|
|
1995
|
+
|
|
1996
|
+
beforeEach(() => {
|
|
1997
|
+
withDistinctTransceivers();
|
|
1998
|
+
// @ts-expect-error private method
|
|
1999
|
+
vi.spyOn(publisher, 'negotiate').mockResolvedValue();
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
it('announces one track per publish option when publish() runs concurrently', async () => {
|
|
2003
|
+
await Promise.all([
|
|
2004
|
+
publisher.publish(makeTrack(), TrackType.VIDEO),
|
|
2005
|
+
publisher.publish(makeTrack(), TrackType.VIDEO),
|
|
2006
|
+
]);
|
|
2007
|
+
|
|
2008
|
+
expect(publisher['transceiverCache'].items()).toHaveLength(1);
|
|
2009
|
+
expect(announcedKeys()).toEqual([`${TrackType.VIDEO}:1`]);
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
it('announces one track per publish option when publish() runs during a codec switch', async () => {
|
|
2013
|
+
const track = makeTrack();
|
|
2014
|
+
await publisher.publish(track, TrackType.VIDEO);
|
|
2015
|
+
|
|
2016
|
+
// stall the first await inside addTransceiver, so the codec switch is
|
|
2017
|
+
// still in-flight when the next publish() looks up the cache
|
|
2018
|
+
let releaseCodecSwitch!: () => void;
|
|
2019
|
+
const codecSwitch = new Promise<void>((resolve) => {
|
|
2020
|
+
releaseCodecSwitch = resolve;
|
|
2021
|
+
});
|
|
2022
|
+
withDistinctTransceivers((transceiver) => {
|
|
2023
|
+
transceiver.sender.setParameters = vi.fn(() => codecSwitch);
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
dispatcher.dispatch(
|
|
2027
|
+
SfuEvent.create({
|
|
2028
|
+
eventPayload: {
|
|
2029
|
+
oneofKind: 'changePublishOptions',
|
|
2030
|
+
changePublishOptions: {
|
|
2031
|
+
publishOptions: [videoOption(1, 'vp9'), videoOption(2, 'av1')],
|
|
2032
|
+
reason: 'test',
|
|
2033
|
+
},
|
|
2034
|
+
},
|
|
2035
|
+
}) as DispatchableMessage<'changePublishOptions'>,
|
|
2036
|
+
'test',
|
|
2037
|
+
);
|
|
2038
|
+
await vi.waitFor(() =>
|
|
2039
|
+
expect(publisher['pc'].addTransceiver).toHaveBeenCalled(),
|
|
2040
|
+
);
|
|
2041
|
+
|
|
2042
|
+
const publishTask = publisher.publish(track, TrackType.VIDEO);
|
|
2043
|
+
// a macrotask boundary drains the pending microtasks, which is as far
|
|
2044
|
+
// as an unserialized publish() can get: up to the cache lookup for the
|
|
2045
|
+
// new publish option. It cannot resolve earlier under load, so this
|
|
2046
|
+
// does not depend on wall-clock timing.
|
|
2047
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2048
|
+
releaseCodecSwitch();
|
|
2049
|
+
await publishTask;
|
|
2050
|
+
await settled(publisher['eventLockKey']('changePublishOptions'));
|
|
2051
|
+
|
|
2052
|
+
expect(announcedKeys()).toEqual([
|
|
2053
|
+
`${TrackType.VIDEO}:1`,
|
|
2054
|
+
`${TrackType.VIDEO}:2`,
|
|
2055
|
+
]);
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
it('rejects a queued publish whose publish options were retired', async () => {
|
|
2059
|
+
// stall the first negotiation, so the next publish() has to queue
|
|
2060
|
+
const firstNegotiation = promiseWithResolvers<void>();
|
|
2061
|
+
let negotiations = 0;
|
|
2062
|
+
// @ts-expect-error private method
|
|
2063
|
+
vi.spyOn(publisher, 'negotiate').mockImplementation(() =>
|
|
2064
|
+
++negotiations === 1 ? firstNegotiation.promise : Promise.resolve(),
|
|
2065
|
+
);
|
|
2066
|
+
|
|
2067
|
+
const firstPublish = publisher.publish(makeTrack(), TrackType.VIDEO);
|
|
2068
|
+
await vi.waitFor(() => expect(negotiations).toBe(1));
|
|
2069
|
+
|
|
2070
|
+
const queuedPublish = publisher.publish(makeTrack(), TrackType.VIDEO);
|
|
2071
|
+
// video is retired while the second publish waits for the lock
|
|
2072
|
+
publisher['publishOptions'] = [];
|
|
2073
|
+
|
|
2074
|
+
firstNegotiation.resolve();
|
|
2075
|
+
await firstPublish;
|
|
2076
|
+
|
|
2077
|
+
// it must not report success without having published anything
|
|
2078
|
+
await expect(queuedPublish).rejects.toThrow(
|
|
2079
|
+
'No publish options found for VIDEO',
|
|
2080
|
+
);
|
|
2081
|
+
expect(publisher['pc'].addTransceiver).toHaveBeenCalledTimes(1);
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
it('does not keep publishing with an option retired mid-publish', async () => {
|
|
2085
|
+
publisher['publishOptions'] = [
|
|
2086
|
+
videoOption(1, 'vp9'),
|
|
2087
|
+
videoOption(2, 'av1'),
|
|
2088
|
+
];
|
|
2089
|
+
|
|
2090
|
+
// stall the first negotiation, so publish() is suspended in its loop
|
|
2091
|
+
// after option 1 was added but before option 2 is reached
|
|
2092
|
+
const firstNegotiation = promiseWithResolvers<void>();
|
|
2093
|
+
let negotiations = 0;
|
|
2094
|
+
// @ts-expect-error private method
|
|
2095
|
+
vi.spyOn(publisher, 'negotiate').mockImplementation(() =>
|
|
2096
|
+
++negotiations === 1 ? firstNegotiation.promise : Promise.resolve(),
|
|
2097
|
+
);
|
|
2098
|
+
|
|
2099
|
+
const publishTask = publisher.publish(makeTrack(), TrackType.VIDEO);
|
|
2100
|
+
await vi.waitFor(() => expect(negotiations).toBe(1));
|
|
2101
|
+
|
|
2102
|
+
dispatcher.dispatch(
|
|
2103
|
+
SfuEvent.create({
|
|
2104
|
+
eventPayload: {
|
|
2105
|
+
oneofKind: 'changePublishOptions',
|
|
2106
|
+
changePublishOptions: {
|
|
2107
|
+
publishOptions: [videoOption(1, 'vp9')],
|
|
2108
|
+
reason: 'test',
|
|
2109
|
+
},
|
|
2110
|
+
},
|
|
2111
|
+
}) as DispatchableMessage<'changePublishOptions'>,
|
|
2112
|
+
'test',
|
|
2113
|
+
);
|
|
2114
|
+
// the handler runs synchronously, so the retired options are in place
|
|
2115
|
+
// and syncPublishOptions is already queued behind this publish
|
|
2116
|
+
expect(publisher['publishOptions']).toHaveLength(1);
|
|
2117
|
+
|
|
2118
|
+
firstNegotiation.resolve();
|
|
2119
|
+
await publishTask;
|
|
2120
|
+
await settled(publisher['eventLockKey']('changePublishOptions'));
|
|
2121
|
+
|
|
2122
|
+
expect(announcedKeys()).toEqual([`${TrackType.VIDEO}:1`]);
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
it('reuses the transceiver on republish after setParameters failed', async () => {
|
|
2126
|
+
withDistinctTransceivers((transceiver) => {
|
|
2127
|
+
transceiver.sender.setParameters = vi
|
|
2128
|
+
.fn()
|
|
2129
|
+
.mockRejectedValueOnce(new Error('InvalidModificationError'));
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
await expect(
|
|
2133
|
+
publisher.publish(makeTrack(), TrackType.VIDEO),
|
|
2134
|
+
).rejects.toThrow('InvalidModificationError');
|
|
2135
|
+
|
|
2136
|
+
// the transceiver the browser created is tracked, so the republish
|
|
2137
|
+
// reuses it instead of adding a second one for the same publish option
|
|
2138
|
+
await publisher.publish(makeTrack(), TrackType.VIDEO);
|
|
2139
|
+
|
|
2140
|
+
expect(publisher['pc'].addTransceiver).toHaveBeenCalledTimes(1);
|
|
2141
|
+
expect(publisher['transceiverCache'].items()).toHaveLength(1);
|
|
2142
|
+
expect(announcedKeys()).toEqual([`${TrackType.VIDEO}:1`]);
|
|
2143
|
+
});
|
|
2144
|
+
});
|
|
1848
2145
|
});
|
package/src/rtc/types.ts
CHANGED
|
@@ -60,18 +60,20 @@ export type OnIceConnected = (peerType: PeerType) => void;
|
|
|
60
60
|
* Snapshot of the peer connection's ICE and DTLS state surfaced to telemetry
|
|
61
61
|
* consumers (e.g. `ClientEventReporter`). Fired on every transition of
|
|
62
62
|
* either `iceConnectionState` or `peerConnectionState`.
|
|
63
|
+
*
|
|
64
|
+
* `iceConnectionState` is the live `RTCPeerConnection.iceConnectionState` read
|
|
65
|
+
* at the moment the event fires, regardless of which transition triggered it.
|
|
66
|
+
* Consumers must use it (rather than assuming a value from `stateType`) because
|
|
67
|
+
* the browser does not guarantee the relative ordering of the `ice` and
|
|
68
|
+
* `peerConnection` `failed` transitions.
|
|
63
69
|
*/
|
|
64
|
-
export type PeerConnectionStateChangeEvent =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
peerType: PeerType;
|
|
72
|
-
stateType: 'peerConnection';
|
|
73
|
-
state: RTCPeerConnectionState;
|
|
74
|
-
};
|
|
70
|
+
export type PeerConnectionStateChangeEvent = {
|
|
71
|
+
peerType: PeerType;
|
|
72
|
+
iceConnectionState: RTCIceConnectionState;
|
|
73
|
+
} & (
|
|
74
|
+
| { stateType: 'ice'; state: RTCIceConnectionState }
|
|
75
|
+
| { stateType: 'peerConnection'; state: RTCPeerConnectionState }
|
|
76
|
+
);
|
|
75
77
|
|
|
76
78
|
export type OnPeerConnectionStateChange = (
|
|
77
79
|
event: PeerConnectionStateChangeEvent,
|
package/src/store/CallState.ts
CHANGED
|
@@ -408,21 +408,6 @@ export class CallState {
|
|
|
408
408
|
this.thumbnails$ = this.thumbnailsSubject.asObservable();
|
|
409
409
|
this.closedCaptions$ = this.closedCaptionsSubject.asObservable();
|
|
410
410
|
|
|
411
|
-
/**
|
|
412
|
-
* Performs shallow comparison of two arrays.
|
|
413
|
-
* Expects primitive values: [1, 2, 3] is equal to [2, 1, 3].
|
|
414
|
-
*/
|
|
415
|
-
const isShallowEqual = <T>(a: Array<T>, b: Array<T>): boolean => {
|
|
416
|
-
if (a.length !== b.length) return false;
|
|
417
|
-
for (const item of a) {
|
|
418
|
-
if (!b.includes(item)) return false;
|
|
419
|
-
}
|
|
420
|
-
for (const item of b) {
|
|
421
|
-
if (!a.includes(item)) return false;
|
|
422
|
-
}
|
|
423
|
-
return true;
|
|
424
|
-
};
|
|
425
|
-
|
|
426
411
|
/**
|
|
427
412
|
* Creates an Observable from the given subject by piping to the
|
|
428
413
|
* `distinctUntilChanged()` operator.
|
|
@@ -436,7 +421,10 @@ export class CallState {
|
|
|
436
421
|
this.anonymousParticipantCount$ = duc(
|
|
437
422
|
this.anonymousParticipantCountSubject,
|
|
438
423
|
);
|
|
439
|
-
this.blockedUserIds$ = duc(
|
|
424
|
+
this.blockedUserIds$ = duc(
|
|
425
|
+
this.blockedUserIdsSubject,
|
|
426
|
+
RxUtils.isShallowArrayEqual,
|
|
427
|
+
);
|
|
440
428
|
this.backstage$ = duc(this.backstageSubject);
|
|
441
429
|
this.callingState$ = duc(this.callingStateSubject);
|
|
442
430
|
this.ownCapabilities$ = combineLatest([
|
|
@@ -471,7 +459,7 @@ export class CallState {
|
|
|
471
459
|
|
|
472
460
|
return nextCapabilities;
|
|
473
461
|
}),
|
|
474
|
-
distinctUntilChanged(
|
|
462
|
+
distinctUntilChanged(RxUtils.isShallowArrayEqual),
|
|
475
463
|
shareReplay({ bufferSize: 1, refCount: true }),
|
|
476
464
|
);
|
|
477
465
|
this.participantCount$ = duc(this.participantCountSubject);
|
package/src/store/rxUtils.ts
CHANGED
|
@@ -10,6 +10,21 @@ type AsyncFunctionPatch<T> = (currentValue: T) => Promise<T>;
|
|
|
10
10
|
*/
|
|
11
11
|
export type Patch<T> = T | FunctionPatch<T>;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Performs shallow comparison of two arrays.
|
|
15
|
+
* Expects primitive values: [1, 2, 3] is equal to [2, 1, 3].
|
|
16
|
+
*/
|
|
17
|
+
export const isShallowArrayEqual = <T>(a: Array<T>, b: Array<T>): boolean => {
|
|
18
|
+
if (a.length !== b.length) return false;
|
|
19
|
+
for (const item of a) {
|
|
20
|
+
if (!b.includes(item)) return false;
|
|
21
|
+
}
|
|
22
|
+
for (const item of b) {
|
|
23
|
+
if (!a.includes(item)) return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
|
|
13
28
|
/**
|
|
14
29
|
* Checks if the provided update is a function patch.
|
|
15
30
|
*
|