@tribe-nest/media-client 0.1.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.
Files changed (65) hide show
  1. package/README.md +68 -0
  2. package/build/core/index.d.ts +17 -0
  3. package/build/core/index.d.ts.map +1 -0
  4. package/build/core/index.js +41 -0
  5. package/build/core/index.js.map +1 -0
  6. package/build/core/reconnect.d.ts +95 -0
  7. package/build/core/reconnect.d.ts.map +1 -0
  8. package/build/core/reconnect.js +160 -0
  9. package/build/core/reconnect.js.map +1 -0
  10. package/build/core/signal.d.ts +184 -0
  11. package/build/core/signal.d.ts.map +1 -0
  12. package/build/core/signal.js +416 -0
  13. package/build/core/signal.js.map +1 -0
  14. package/build/core/socket.d.ts +57 -0
  15. package/build/core/socket.d.ts.map +1 -0
  16. package/build/core/socket.js +37 -0
  17. package/build/core/socket.js.map +1 -0
  18. package/build/core/state.d.ts +67 -0
  19. package/build/core/state.d.ts.map +1 -0
  20. package/build/core/state.js +193 -0
  21. package/build/core/state.js.map +1 -0
  22. package/build/index.d.ts +29 -0
  23. package/build/index.d.ts.map +1 -0
  24. package/build/index.js +51 -0
  25. package/build/index.js.map +1 -0
  26. package/build/protocol.d.ts +10 -0
  27. package/build/protocol.d.ts.map +1 -0
  28. package/build/protocol.js +26 -0
  29. package/build/protocol.js.map +1 -0
  30. package/build/react/index.d.ts +147 -0
  31. package/build/react/index.d.ts.map +1 -0
  32. package/build/react/index.js +319 -0
  33. package/build/react/index.js.map +1 -0
  34. package/build/room/browserDevice.d.ts +3 -0
  35. package/build/room/browserDevice.d.ts.map +1 -0
  36. package/build/room/browserDevice.js +94 -0
  37. package/build/room/browserDevice.js.map +1 -0
  38. package/build/room/device.d.ts +114 -0
  39. package/build/room/device.d.ts.map +1 -0
  40. package/build/room/device.js +3 -0
  41. package/build/room/device.js.map +1 -0
  42. package/build/room/room.d.ts +219 -0
  43. package/build/room/room.d.ts.map +1 -0
  44. package/build/room/room.js +438 -0
  45. package/build/room/room.js.map +1 -0
  46. package/package.json +69 -0
  47. package/src/_tests/clientBoundary.spec.ts +110 -0
  48. package/src/core/_tests/coreBoundary.spec.ts +70 -0
  49. package/src/core/_tests/fakeSignalServer.ts +188 -0
  50. package/src/core/_tests/reconnect.spec.ts +180 -0
  51. package/src/core/_tests/signal.spec.ts +347 -0
  52. package/src/core/_tests/state.spec.ts +226 -0
  53. package/src/core/index.ts +63 -0
  54. package/src/core/reconnect.ts +233 -0
  55. package/src/core/signal.ts +527 -0
  56. package/src/core/socket.ts +58 -0
  57. package/src/core/state.ts +251 -0
  58. package/src/index.ts +54 -0
  59. package/src/protocol.ts +9 -0
  60. package/src/react/_tests/hooks.spec.tsx +509 -0
  61. package/src/react/index.tsx +439 -0
  62. package/src/room/_tests/room.spec.ts +595 -0
  63. package/src/room/browserDevice.ts +114 -0
  64. package/src/room/device.ts +119 -0
  65. package/src/room/room.ts +600 -0
@@ -0,0 +1,509 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, render, waitFor } from "@testing-library/react";
3
+ import { useEffect, useRef } from "react";
4
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
5
+
6
+ import { FakeSignalServer } from "../../core/_tests/fakeSignalServer";
7
+ import type { MediaDevice, MediaTransport } from "../../room/device";
8
+ import {
9
+ MediaRoomProvider,
10
+ useLocalMedia,
11
+ useParticipants,
12
+ useRemoteTrack,
13
+ useRoomState,
14
+ useVisibleProducers,
15
+ } from "../index";
16
+
17
+ /**
18
+ * The hooks, rendered.
19
+ *
20
+ * ## The bug this file exists for
21
+ *
22
+ * `useSyncExternalStore` compares snapshots with `Object.is`. A selector that
23
+ * builds a new array on every call therefore reports a change on every check,
24
+ * and React re-renders for ever - "The result of getSnapshot should be cached
25
+ * to avoid an infinite loop". `useRemoteTrack` and `useLocalMedia` both did
26
+ * exactly that, because `room.tracks` and `room.localPublications` were getters
27
+ * that spread a Map.
28
+ *
29
+ * It is not a subtle degradation: the hooks were unusable, and nothing in the
30
+ * type system or the unit tests could see it, because the loop only exists once
31
+ * React is actually driving them.
32
+ *
33
+ * So every test here counts RENDERS. A hook that settles is the property; a
34
+ * hook that returns the right value while looping is not passing.
35
+ */
36
+
37
+ // Not configured globally in this package, so a render from one test would
38
+ // otherwise still be mounted during the next and every query would find two.
39
+ afterEach(cleanup);
40
+
41
+ beforeAll(() => {
42
+ // jsdom has neither, and `attach` builds a `MediaStream` to put on an
43
+ // element. Stand-ins are enough: what is asserted is WHICH tracks reach the
44
+ // element and when, not what the browser does with them afterwards.
45
+ if (!("MediaStream" in globalThis)) {
46
+ (globalThis as { MediaStream?: unknown }).MediaStream = class {
47
+ constructor(readonly tracks: unknown[] = []) {}
48
+ };
49
+ }
50
+ });
51
+
52
+ const fakeDevice = (): MediaDevice => ({
53
+ async load() {},
54
+ loaded: true,
55
+ rtpCapabilities: { codecs: [] } as never,
56
+ canProduce: () => true,
57
+ createSendTransport: () => fakeTransport(),
58
+ createRecvTransport: () => fakeTransport(),
59
+ });
60
+
61
+ const fakeTransport = (): MediaTransport => ({
62
+ id: "t-1",
63
+ async produce() {
64
+ return { id: "p-1", kind: "audio", closed: false, pause: vi.fn(), resume: vi.fn(), close: vi.fn() };
65
+ },
66
+ async consume(input) {
67
+ return {
68
+ id: "c-1",
69
+ producerId: input.producerId,
70
+ kind: input.kind,
71
+ track: { kind: input.kind, stop: vi.fn() } as unknown as MediaStreamTrack,
72
+ pause: vi.fn(),
73
+ resume: vi.fn(),
74
+ close: vi.fn(),
75
+ };
76
+ },
77
+ close: vi.fn(),
78
+ });
79
+
80
+ function nodeWith(producers: { producerId: string; identity: string; kind: "audio" | "video" }[] = []) {
81
+ const server: FakeSignalServer = new FakeSignalServer({
82
+ joined: {
83
+ identity: "me",
84
+ room: "matter-1",
85
+ routerRtpCapabilities: { codecs: [] },
86
+ producers,
87
+ grants: { canPublish: true, canSubscribe: true, canPublishData: false },
88
+ },
89
+ onRequest: (frame, socket) => {
90
+ if (frame.method === "join") return;
91
+ if (frame.method === "createTransport") {
92
+ return server.reply(frame.id, { transportId: "t-1", iceParameters: {}, iceCandidates: [], dtlsParameters: {} }, socket);
93
+ }
94
+ if (frame.method === "consume") {
95
+ return server.reply(frame.id, { consumerId: "c-1", producerId: frame.producerId, kind: "audio", rtpParameters: {} }, socket);
96
+ }
97
+ return server.reply(frame.id, {}, socket);
98
+ },
99
+ });
100
+ return server;
101
+ }
102
+
103
+ /** Counts every render of the component it is used in. */
104
+ function useRenderCount(): { current: number } {
105
+ const count = useRef(0);
106
+ count.current++;
107
+ return count;
108
+ }
109
+
110
+ const wrap = (server: FakeSignalServer, children: React.ReactNode) => (
111
+ <MediaRoomProvider
112
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
113
+ device={fakeDevice}
114
+ webSocket={server.factory}
115
+ autoSubscribe={false}
116
+ >
117
+ {children}
118
+ </MediaRoomProvider>
119
+ );
120
+
121
+ describe("hooks settle instead of looping", () => {
122
+ it("useRemoteTrack does not re-render for ever", async () => {
123
+ const renders: number[] = [];
124
+ function Probe() {
125
+ const count = useRenderCount();
126
+ const { track } = useRemoteTrack("p-a");
127
+ useEffect(() => {
128
+ renders.push(count.current);
129
+ });
130
+ return <div data-testid="track">{track ? "yes" : "no"}</div>;
131
+ }
132
+
133
+ const server = nodeWith([{ producerId: "p-a", identity: "a", kind: "audio" }]);
134
+ render(wrap(server, <Probe />));
135
+
136
+ // Settled, not merely correct. A looping hook also renders "no".
137
+ await new Promise((r) => setTimeout(r, 150));
138
+ const settled = renders.at(-1) ?? 0;
139
+ expect(settled).toBeLessThan(15);
140
+
141
+ await new Promise((r) => setTimeout(r, 150));
142
+ expect(renders.at(-1)).toBe(settled);
143
+ });
144
+
145
+ it("useLocalMedia does not re-render for ever", async () => {
146
+ const renders: number[] = [];
147
+ function Probe() {
148
+ const count = useRenderCount();
149
+ const { isCameraEnabled } = useLocalMedia();
150
+ useEffect(() => {
151
+ renders.push(count.current);
152
+ });
153
+ return <div>{String(isCameraEnabled)}</div>;
154
+ }
155
+
156
+ render(wrap(nodeWith(), <Probe />));
157
+
158
+ await new Promise((r) => setTimeout(r, 150));
159
+ const settled = renders.at(-1) ?? 0;
160
+ expect(settled).toBeLessThan(15);
161
+
162
+ await new Promise((r) => setTimeout(r, 150));
163
+ expect(renders.at(-1)).toBe(settled);
164
+ });
165
+
166
+ it("useRoomState and useParticipants settle too", async () => {
167
+ const renders: number[] = [];
168
+ function Probe() {
169
+ const count = useRenderCount();
170
+ const state = useRoomState();
171
+ const peers = useParticipants();
172
+ useEffect(() => {
173
+ renders.push(count.current);
174
+ });
175
+ return <div>{`${state.phase}:${peers.length}`}</div>;
176
+ }
177
+
178
+ render(wrap(nodeWith(), <Probe />));
179
+ await new Promise((r) => setTimeout(r, 150));
180
+ const settled = renders.at(-1) ?? 0;
181
+ expect(settled).toBeLessThan(15);
182
+ });
183
+ });
184
+
185
+ describe("the room reaches the hooks", () => {
186
+ it("reports the joined state", async () => {
187
+ function Probe() {
188
+ const state = useRoomState();
189
+ return <div data-testid="phase">{state.phase}</div>;
190
+ }
191
+
192
+ const { getByTestId } = render(wrap(nodeWith(), <Probe />));
193
+ await waitFor(() => expect(getByTestId("phase").textContent).toBe("joined"));
194
+ });
195
+
196
+ it("exposes a track once it has been subscribed", async () => {
197
+ function Probe() {
198
+ const { track } = useRemoteTrack("p-a");
199
+ return <div data-testid="track">{track?.identity ?? "none"}</div>;
200
+ }
201
+
202
+ const server = nodeWith([{ producerId: "p-a", identity: "anwalt", kind: "audio" }]);
203
+ const { getByTestId } = render(
204
+ <MediaRoomProvider
205
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
206
+ device={fakeDevice}
207
+ webSocket={server.factory}
208
+ >
209
+ <Probe />
210
+ </MediaRoomProvider>,
211
+ );
212
+
213
+ // The socket exists only once the provider's effect has run, so the event
214
+ // has to wait for the join rather than racing it.
215
+ await waitFor(() => expect(server.sockets.length).toBeGreaterThan(0));
216
+ await waitFor(() => expect(server.received.some((f) => f.method === "join")).toBe(true));
217
+
218
+ // autoSubscribe on, and the node has said the producer is in the set.
219
+ server.event({ event: "activeSpeakers", producerIds: ["p-a"] });
220
+ await waitFor(() => expect(getByTestId("track").textContent).toBe("anwalt"));
221
+ });
222
+
223
+ /**
224
+ * The element is mounted BEFORE the track exists, which is the ordinary case.
225
+ *
226
+ * A producer reaches room state the moment the node announces it; the consume
227
+ * round trip that yields a track finishes some milliseconds later. An element
228
+ * rendered unconditionally - which is what remote audio has to be, since a
229
+ * participant with the camera off still has a voice - therefore takes its ref
230
+ * callback while `tracks` is still empty. Keyed on `[room, producerId]`, both
231
+ * stable, that callback set `srcObject` to `null` once and was never invoked
232
+ * again: the whole call was silent, in both directions, on a screen that
233
+ * looked completely correct.
234
+ */
235
+ it("attaches a track that arrives AFTER the element mounted", async () => {
236
+ function Probe() {
237
+ const { attach } = useRemoteTrack("p-a");
238
+ return <audio ref={attach} data-testid="audio" />;
239
+ }
240
+
241
+ const server = nodeWith([{ producerId: "p-a", identity: "anwalt", kind: "audio" }]);
242
+ const { getByTestId } = render(
243
+ <MediaRoomProvider
244
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
245
+ device={fakeDevice}
246
+ webSocket={server.factory}
247
+ >
248
+ <Probe />
249
+ </MediaRoomProvider>,
250
+ );
251
+
252
+ await waitFor(() => expect(server.received.some((f) => f.method === "join")).toBe(true));
253
+ const element = getByTestId("audio") as HTMLAudioElement;
254
+ // Nothing yet, and this is the state the bug froze the element in.
255
+ expect(element.srcObject).toBeNull();
256
+
257
+ server.event({ event: "activeSpeakers", producerIds: ["p-a"] });
258
+
259
+ await waitFor(() => expect(getByTestId("audio").srcObject).not.toBeNull());
260
+ // The SAME element. A test that re-queried and found a freshly mounted one
261
+ // would prove nothing about the element that was already on the page.
262
+ expect(getByTestId("audio")).toBe(element);
263
+ });
264
+ });
265
+
266
+ /**
267
+ * A capture the room never took is still a live camera.
268
+ *
269
+ * `getUserMedia` turns the light on. If the publish that follows fails, no
270
+ * publication is recorded, so the UI still reads "Start camera" and
271
+ * `unpublish("camera")` has nothing to close: the light stays on with no
272
+ * control that turns it off, and every retry opens another capture.
273
+ */
274
+ describe("a publish that fails does not leave the device running", () => {
275
+ const captureWith = (tracks: MediaStreamTrack[]) => {
276
+ const getUserMedia = vi.fn(async () => ({ getTracks: () => tracks }) as unknown as MediaStream);
277
+ const getDisplayMedia = vi.fn(async () => ({ getTracks: () => tracks }) as unknown as MediaStream);
278
+ Object.defineProperty(navigator, "mediaDevices", {
279
+ configurable: true,
280
+ value: { getUserMedia, getDisplayMedia },
281
+ });
282
+ return { getUserMedia, getDisplayMedia };
283
+ };
284
+
285
+ const fakeTrack = (kind: "audio" | "video") =>
286
+ ({ kind, stop: vi.fn(), enabled: true }) as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
287
+
288
+ afterEach(() => {
289
+ delete (navigator as { mediaDevices?: unknown }).mediaDevices;
290
+ });
291
+
292
+ /** A device whose transport refuses to produce, as the node refusing a grant. */
293
+ const refusingDevice = (): MediaDevice => ({
294
+ ...fakeDevice(),
295
+ createSendTransport: () => ({
296
+ ...fakeTransport(),
297
+ async produce() {
298
+ throw new Error("this token may not publish camera");
299
+ },
300
+ }),
301
+ });
302
+
303
+ it("stops the camera when the produce is refused", async () => {
304
+ const track = fakeTrack("video");
305
+ captureWith([track]);
306
+
307
+ let controls: ReturnType<typeof useLocalMedia> | undefined;
308
+ function Probe() {
309
+ controls = useLocalMedia();
310
+ return <div>{String(controls.isCameraEnabled)}</div>;
311
+ }
312
+
313
+ render(
314
+ <MediaRoomProvider
315
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
316
+ device={refusingDevice}
317
+ webSocket={nodeWith().factory}
318
+ autoSubscribe={false}
319
+ >
320
+ <Probe />
321
+ </MediaRoomProvider>,
322
+ );
323
+
324
+ await waitFor(() => expect(controls).toBeDefined());
325
+ await act(async () => {
326
+ await controls?.publishCamera();
327
+ });
328
+
329
+ // The refusal is shown, AND the light is out. Only the first of those was
330
+ // true, and the second is the one the person sitting in front of it sees.
331
+ await waitFor(() => expect(controls?.error?.message).toContain("may not publish"));
332
+ expect(track.stop).toHaveBeenCalledTimes(1);
333
+ expect(controls?.isCameraEnabled).toBe(false);
334
+ });
335
+
336
+ it("stops the extra tracks a capture carries but a publish never takes", async () => {
337
+ // Chrome adds a system-audio track to a screen share when the person ticks
338
+ // "share audio". One track is published; the other is ours to close.
339
+ const video = fakeTrack("video");
340
+ const audio = fakeTrack("audio");
341
+ captureWith([video, audio]);
342
+
343
+ let controls: ReturnType<typeof useLocalMedia> | undefined;
344
+ function Probe() {
345
+ controls = useLocalMedia();
346
+ return <div>{String(controls.screenSharing)}</div>;
347
+ }
348
+
349
+ render(
350
+ <MediaRoomProvider
351
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
352
+ device={fakeDevice}
353
+ webSocket={nodeWith().factory}
354
+ autoSubscribe={false}
355
+ >
356
+ <Probe />
357
+ </MediaRoomProvider>,
358
+ );
359
+
360
+ await waitFor(() => expect(controls).toBeDefined());
361
+ await act(async () => {
362
+ await controls?.publishScreen();
363
+ });
364
+
365
+ await waitFor(() => expect(controls?.screenSharing).toBe(true));
366
+ // The published one belongs to the room now, and the room's teardown stops
367
+ // it on every path out of a call, not only a deliberate leave.
368
+ expect(video.stop).not.toHaveBeenCalled();
369
+ expect(audio.stop).toHaveBeenCalledTimes(1);
370
+ });
371
+ });
372
+
373
+ /**
374
+ * The second press, which is the ordinary one.
375
+ *
376
+ * Every control over this hook is a read-then-write: it reads
377
+ * `isMicrophoneEnabled` and publishes or unpublishes accordingly. That flag
378
+ * comes from `publications`, which does not move until the publish RESOLVES,
379
+ * and a publish opens with a `getUserMedia` permission prompt that sits on
380
+ * screen for seconds. So a person who presses Unmute, sees nothing change, and
381
+ * presses it again used to put two captures through two `room.publish` calls:
382
+ * two audio producers under one identity, everyone else mounting two `<audio>`
383
+ * elements and hearing them doubled, and unpublishing both afterwards does not
384
+ * give back the seconds of garbled audio. For a screen share it opens a second
385
+ * picker.
386
+ */
387
+ describe("one press, one publish", () => {
388
+ /** A capture that hangs, as a permission prompt does. Released by hand. */
389
+ const promptThatHangs = () => {
390
+ let release!: () => void;
391
+ const answered = new Promise<void>((resolve) => {
392
+ release = resolve;
393
+ });
394
+ const track = { kind: "audio", stop: vi.fn(), enabled: true } as unknown as MediaStreamTrack;
395
+ const getUserMedia = vi.fn(async () => {
396
+ await answered;
397
+ return { getTracks: () => [track] } as unknown as MediaStream;
398
+ });
399
+ Object.defineProperty(navigator, "mediaDevices", {
400
+ configurable: true,
401
+ value: { getUserMedia, getDisplayMedia: getUserMedia },
402
+ });
403
+ return { getUserMedia, release };
404
+ };
405
+
406
+ const countingDevice = (produced: string[]): MediaDevice => ({
407
+ ...fakeDevice(),
408
+ createSendTransport: () => ({
409
+ ...fakeTransport(),
410
+ async produce(input: { appData?: Record<string, unknown> }) {
411
+ produced.push(String(input.appData?.source));
412
+ return { id: `p-${produced.length}`, kind: "audio" as const, closed: false, pause: vi.fn(), resume: vi.fn(), close: vi.fn() };
413
+ },
414
+ }),
415
+ });
416
+
417
+ afterEach(() => {
418
+ delete (navigator as { mediaDevices?: unknown }).mediaDevices;
419
+ });
420
+
421
+ it("captures and produces ONCE when the control is pressed twice in a tick", async () => {
422
+ const { getUserMedia, release } = promptThatHangs();
423
+ const produced: string[] = [];
424
+
425
+ let controls: ReturnType<typeof useLocalMedia> | undefined;
426
+ function Probe() {
427
+ controls = useLocalMedia();
428
+ return <div data-testid="pending">{String(controls.pending.microphone)}</div>;
429
+ }
430
+
431
+ const { getByTestId } = render(
432
+ <MediaRoomProvider
433
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
434
+ device={() => countingDevice(produced)}
435
+ webSocket={nodeWith().factory}
436
+ autoSubscribe={false}
437
+ >
438
+ <Probe />
439
+ </MediaRoomProvider>,
440
+ );
441
+
442
+ await waitFor(() => expect(controls).toBeDefined());
443
+
444
+ // Both in ONE tick, before React has re-rendered anything. That is why the
445
+ // guard has to be a ref: a state flag reads `false` in both handlers.
446
+ let both: Promise<unknown> | undefined;
447
+ await act(async () => {
448
+ both = Promise.all([controls?.publishMicrophone(), controls?.publishMicrophone()]);
449
+ });
450
+
451
+ // And the control says it is working, rather than looking untouched.
452
+ expect(getByTestId("pending").textContent).toBe("true");
453
+
454
+ await act(async () => {
455
+ release();
456
+ await both;
457
+ });
458
+
459
+ expect(getUserMedia).toHaveBeenCalledTimes(1);
460
+ expect(produced).toEqual(["microphone"]);
461
+ expect(controls?.isMicrophoneEnabled).toBe(true);
462
+ expect(getByTestId("pending").textContent).toBe("false");
463
+ });
464
+ });
465
+
466
+ /**
467
+ * The subscribe barrier, applied where it is rendered.
468
+ *
469
+ * `useRoomState().producers` is every producer the node has ever announced,
470
+ * kept deliberately so that a rule which narrows mid-call can later widen -
471
+ * there is no `producerAppeared` replay to bring back what a prune deleted. The
472
+ * price is that the raw list holds people this seat has been barred from, and
473
+ * the node does not clean up for us: `revokeNewlyForbidden` closes only
474
+ * producers this session had a LIVE consumer for, so one that was announced and
475
+ * never consumed stays in the list for the rest of the call. On a sealed room
476
+ * the other party's presence is most of what the barrier was hiding.
477
+ */
478
+ describe("useVisibleProducers applies the barrier the raw list deliberately does not", () => {
479
+ it("drops a producer the node has barred, without pruning the room's own record", async () => {
480
+ const seen: string[][] = [];
481
+ function Probe() {
482
+ const visible = useVisibleProducers();
483
+ const state = useRoomState();
484
+ seen.push(visible.map((p) => p.producerId));
485
+ return <div data-testid="visible">{`${visible.length}/${state.producers.length}`}</div>;
486
+ }
487
+
488
+ const server = nodeWith([
489
+ { producerId: "p-client", identity: "client", kind: "audio" },
490
+ { producerId: "p-third-party", identity: "third-party", kind: "audio" },
491
+ ]);
492
+ const { getByTestId } = render(wrap(server, <Probe />));
493
+
494
+ await waitFor(() => expect(getByTestId("visible").textContent).toBe("2/2"));
495
+
496
+ server.event({ event: "subscribeRuleChanged", subscribe: { mode: "allow", identities: ["client"] } });
497
+
498
+ // One rendered, two remembered. Pruning the record instead would make a
499
+ // barrier that later widens unrecoverable.
500
+ await waitFor(() => expect(getByTestId("visible").textContent).toBe("1/2"));
501
+ expect(seen.at(-1)).toEqual(["p-client"]);
502
+
503
+ // And it settles: the filter builds a new array per call, so a version of
504
+ // this read through `useSyncExternalStore` would re-render for ever.
505
+ const settled = seen.length;
506
+ await new Promise((r) => setTimeout(r, 150));
507
+ expect(seen.length).toBe(settled);
508
+ });
509
+ });