livekit-client 2.19.0 → 2.19.2

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.
@@ -1,5 +1,5 @@
1
1
  import { ClientInfo_Capability, JoinResponse } from '@livekit/protocol';
2
- import { describe, expect, it, vi } from 'vitest';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
  import Room from './Room';
4
4
  import { roomConnectOptionDefaults, roomOptionDefaults } from './defaults';
5
5
  import { RoomEvent } from './events';
@@ -89,3 +89,101 @@ describe('Room signaling options', () => {
89
89
  );
90
90
  });
91
91
  });
92
+
93
+ describe('Room lifecycle', () => {
94
+ afterEach(() => {
95
+ vi.restoreAllMocks();
96
+ // Tear down the mocked mediaDevices so other tests see the env they
97
+ // expected (happy-dom does not provide navigator.mediaDevices by default).
98
+ if ((navigator as { mediaDevices?: unknown }).mediaDevices) {
99
+ Object.defineProperty(navigator, 'mediaDevices', {
100
+ configurable: true,
101
+ value: undefined,
102
+ });
103
+ }
104
+ });
105
+
106
+ it('wraps the constructor-registered devicechange listener in a WeakRef so the Room is GC-eligible (#1940)', async () => {
107
+ // happy-dom does not provide navigator.mediaDevices. Install a minimal
108
+ // EventTarget stand-in so the constructor takes the listener-registration
109
+ // branch and we can observe the registered listener.
110
+ const mediaDevices = new EventTarget() as EventTarget & {
111
+ addEventListener: EventTarget['addEventListener'];
112
+ removeEventListener: EventTarget['removeEventListener'];
113
+ };
114
+ Object.defineProperty(navigator, 'mediaDevices', {
115
+ configurable: true,
116
+ value: mediaDevices,
117
+ });
118
+
119
+ const addSpy = vi.spyOn(mediaDevices, 'addEventListener');
120
+ const derefSpy = vi.spyOn(WeakRef.prototype, 'deref');
121
+ const cleanupRegistrySpy = Room.cleanupRegistry
122
+ ? vi.spyOn(Room.cleanupRegistry, 'register')
123
+ : undefined;
124
+
125
+ const room = new Room();
126
+ const handleDeviceChangeSpy = vi.spyOn(
127
+ room as unknown as { handleDeviceChange: (ev: Event) => void },
128
+ 'handleDeviceChange',
129
+ );
130
+
131
+ // Constructor must register exactly one devicechange listener with AbortSignal teardown.
132
+ const deviceChangeAdds = addSpy.mock.calls.filter(([type]) => type === 'devicechange');
133
+ expect(deviceChangeAdds).toHaveLength(1);
134
+ const listener = deviceChangeAdds[0][1] as EventListener;
135
+ const addOptions = deviceChangeAdds[0][2] as AddEventListenerOptions | undefined;
136
+ expect(addOptions?.signal).toBeInstanceOf(AbortSignal);
137
+
138
+ // FinalizationRegistry must be registered with the Room as the target so the
139
+ // cleanup callback fires when the user drops their Room reference.
140
+ if (Room.cleanupRegistry) {
141
+ expect(cleanupRegistrySpy).toHaveBeenCalledWith(room, expect.any(Function));
142
+ }
143
+
144
+ // While the WeakRef still derefs to the Room, the listener forwards to handleDeviceChange.
145
+ listener.call(null, new Event('devicechange'));
146
+ expect(handleDeviceChangeSpy).toHaveBeenCalledTimes(1);
147
+
148
+ // Simulate the Room being GC'd by forcing deref to return undefined; the
149
+ // listener must short-circuit instead of calling handleDeviceChange.
150
+ derefSpy.mockReturnValue(undefined);
151
+ listener.call(null, new Event('devicechange'));
152
+ expect(handleDeviceChangeSpy).toHaveBeenCalledTimes(1);
153
+ });
154
+
155
+ it('falls back to a direct devicechange listener when WeakRef/FinalizationRegistry are unavailable (#1944)', async () => {
156
+ const mediaDevices = new EventTarget() as EventTarget & {
157
+ addEventListener: EventTarget['addEventListener'];
158
+ removeEventListener: EventTarget['removeEventListener'];
159
+ };
160
+ Object.defineProperty(navigator, 'mediaDevices', {
161
+ configurable: true,
162
+ value: mediaDevices,
163
+ });
164
+
165
+ // Simulate a legacy browser by stubbing out cleanupRegistry.
166
+ const originalRegistry = Room.cleanupRegistry;
167
+ Object.defineProperty(Room, 'cleanupRegistry', {
168
+ configurable: true,
169
+ value: false,
170
+ });
171
+
172
+ try {
173
+ const addSpy = vi.spyOn(mediaDevices, 'addEventListener');
174
+ const room = new Room();
175
+ const handleDeviceChange = (room as unknown as { handleDeviceChange: () => void })
176
+ .handleDeviceChange;
177
+
178
+ const deviceChangeAdds = addSpy.mock.calls.filter(([type]) => type === 'devicechange');
179
+ expect(deviceChangeAdds).toHaveLength(1);
180
+ // The registered listener is the bare handleDeviceChange method (no WeakRef closure).
181
+ expect(deviceChangeAdds[0][1]).toBe(handleDeviceChange);
182
+ } finally {
183
+ Object.defineProperty(Room, 'cleanupRegistry', {
184
+ configurable: true,
185
+ value: originalRegistry,
186
+ });
187
+ }
188
+ });
189
+ });
package/src/room/Room.ts CHANGED
@@ -373,18 +373,34 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
373
373
  }
374
374
 
375
375
  if (isWeb()) {
376
- const abortController = new AbortController();
377
-
378
- // in order to catch device changes prior to room connection we need to register the event in the constructor
379
- navigator.mediaDevices?.addEventListener?.('devicechange', this.handleDeviceChange, {
380
- signal: abortController.signal,
381
- });
376
+ const cleanupController = new AbortController();
377
+ let onDeviceChange: () => void;
382
378
 
383
379
  if (Room.cleanupRegistry) {
380
+ // Wrap the listener in a WeakRef closure so navigator.mediaDevices does not
381
+ // strongly retain the Room. When the user drops their Room ref, the
382
+ // FinalizationRegistry callback aborts the controller and removes the listener.
383
+ const roomRef = new WeakRef(this);
384
+ onDeviceChange = () => {
385
+ const self = roomRef.deref();
386
+ if (!self) {
387
+ return;
388
+ }
389
+ self.handleDeviceChange();
390
+ };
384
391
  Room.cleanupRegistry.register(this, () => {
385
- abortController.abort();
392
+ cleanupController.abort();
386
393
  });
394
+ } else {
395
+ // Legacy browsers without WeakRef/FinalizationRegistry: fall back to a
396
+ // direct listener (matches pre-#1944 behavior).
397
+ onDeviceChange = this.handleDeviceChange;
387
398
  }
399
+
400
+ // in order to catch device changes prior to room connection we need to register the event in the constructor
401
+ navigator.mediaDevices?.addEventListener?.('devicechange', onDeviceChange, {
402
+ signal: cleanupController.signal,
403
+ });
388
404
  }
389
405
  }
390
406
 
@@ -755,6 +771,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
755
771
 
756
772
  static cleanupRegistry =
757
773
  typeof FinalizationRegistry !== 'undefined' &&
774
+ typeof WeakRef !== 'undefined' &&
758
775
  new FinalizationRegistry((cleanup: () => void) => {
759
776
  cleanup();
760
777
  });
@@ -86,11 +86,14 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm
86
86
 
87
87
  const completionFuture = new Future<string, RpcError>();
88
88
 
89
+ let responseTimeoutId: ReturnType<typeof setTimeout> | null = null;
89
90
  const ackTimeoutId = setTimeout(() => {
90
91
  this.pendingAcks.delete(id);
91
92
  completionFuture.reject?.(RpcError.builtIn('CONNECTION_TIMEOUT'));
92
93
  this.pendingResponses.delete(id);
93
- clearTimeout(responseTimeoutId);
94
+ if (responseTimeoutId !== null) {
95
+ clearTimeout(responseTimeoutId);
96
+ }
94
97
  }, maxRoundTripLatencyMs);
95
98
 
96
99
  this.pendingAcks.set(id, {
@@ -114,7 +117,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm
114
117
  remoteClientProtocol,
115
118
  );
116
119
 
117
- const responseTimeoutId = setTimeout(() => {
120
+ responseTimeoutId = setTimeout(() => {
118
121
  this.pendingResponses.delete(id);
119
122
  completionFuture.reject?.(RpcError.builtIn('RESPONSE_TIMEOUT'));
120
123
  }, responseTimeoutMs);
package/src/room/utils.ts CHANGED
@@ -794,3 +794,8 @@ export function extractMaxAgeFromRequestHeaders(headers: Headers): number | unde
794
794
  export function isCompressionStreamSupported() {
795
795
  return typeof CompressionStream !== 'undefined';
796
796
  }
797
+
798
+ export function isPublisherOfferWithJoinSupported() {
799
+ // we have connectivity issue about publisher offer with join on firefox #1919
800
+ return isCompressionStreamSupported() && !isFireFox();
801
+ }