@ksp-gonogo/kerbcast-react 1.10.1 → 1.10.3

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/dist/context.js CHANGED
@@ -3,6 +3,19 @@ import { createContext, useContext, useMemo, useRef, } from "react";
3
3
  /** Build the default refcounted subscriptions implementation for a client. */
4
4
  export function createClientSubscriptions(client) {
5
5
  const refcounts = new Map();
6
+ /*
7
+ * A reconnect clears every slot binding on the client and the sidecar starts
8
+ * the new peer empty, so re-subscribe each camera still held. Listeners fire
9
+ * in registration order and the provider builds this before the display-size
10
+ * registry, so subscriptions go out first. The sidecar treats a repeat
11
+ * subscribe for a bound camera as a re-announce, and a rejection (for
12
+ * example a full slot pool) is swallowed like every other subscribe here.
13
+ */
14
+ client.on("control-open", () => {
15
+ for (const flightId of refcounts.keys()) {
16
+ client.subscribe(flightId).catch(() => { });
17
+ }
18
+ });
6
19
  return {
7
20
  acquire(flightId) {
8
21
  const count = (refcounts.get(flightId) ?? 0) + 1;
@@ -53,6 +66,17 @@ export function createClientDisplaySizes(client) {
53
66
  lastSent.set(flightId, { w: maxW, h: maxH });
54
67
  client.reportDisplaySize(flightId, maxW, maxH).catch(() => { });
55
68
  }
69
+ /*
70
+ * The sidecar forgets a peer's display sizes when the peer leaves, so after
71
+ * a reconnect nothing it holds matches lastSent. Re-send the current MAX for
72
+ * every camera that still has reporters, once the new control channel is
73
+ * open and can take messages.
74
+ */
75
+ client.on("control-open", () => {
76
+ lastSent.clear();
77
+ for (const flightId of [...perCamera.keys()])
78
+ recompute(flightId);
79
+ });
56
80
  return {
57
81
  report(flightId, instanceId, width, height) {
58
82
  let instances = perCamera.get(flightId);
@@ -15,7 +15,8 @@ export interface ReportDisplaySizeOptions {
15
15
  * Self-measure a feed element and drive the client's per-consumer
16
16
  * `reportDisplaySize` (via the context display-size registry, which collapses
17
17
  * multiple feeds of the same camera to one MAX report). Attach `ref` to the
18
- * element whose rendered pixel box is the display size. Reporting follows the
18
+ * element whose rendered pixel box is the display size; an element that
19
+ * appears or changes after mount is picked up on the host's next render. Reporting follows the
19
20
  * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
20
21
  * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
21
22
  * server-side clear.
@@ -1,4 +1,4 @@
1
- import { useEffect, useId } from "react";
1
+ import { useEffect, useId, useLayoutEffect, useState } from "react";
2
2
  import { useKerbcastDisplaySizes } from "../context";
3
3
  /*
4
4
  * Debounce + bucket for self-measured display-size reporting. The first
@@ -18,7 +18,8 @@ function bucket(px) {
18
18
  * Self-measure a feed element and drive the client's per-consumer
19
19
  * `reportDisplaySize` (via the context display-size registry, which collapses
20
20
  * multiple feeds of the same camera to one MAX report). Attach `ref` to the
21
- * element whose rendered pixel box is the display size. Reporting follows the
21
+ * element whose rendered pixel box is the display size; an element that
22
+ * appears or changes after mount is picked up on the host's next render. Reporting follows the
22
23
  * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
23
24
  * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
24
25
  * server-side clear.
@@ -26,10 +27,20 @@ function bucket(px) {
26
27
  export function useReportDisplaySize(flightId, ref, { enabled = true, square = false } = {}) {
27
28
  const displaySizes = useKerbcastDisplaySizes();
28
29
  const instanceId = useId();
30
+ /*
31
+ * Track the element behind the ref as state so an element that mounts or is
32
+ * swapped after the first render (conditional render, a feed that appears
33
+ * once a stream arrives) is observed. Checked after every commit of the
34
+ * host component; the state only changes when the element does.
35
+ */
36
+ const [el, setEl] = useState(null);
37
+ useLayoutEffect(() => {
38
+ if (ref.current !== el)
39
+ setEl(ref.current);
40
+ });
29
41
  useEffect(() => {
30
42
  if (flightId === null || !enabled)
31
43
  return;
32
- const el = ref.current;
33
44
  if (!el)
34
45
  return;
35
46
  let timer = null;
@@ -67,5 +78,5 @@ export function useReportDisplaySize(flightId, ref, { enabled = true, square = f
67
78
  clearTimeout(timer);
68
79
  displaySizes.clear(flightId, instanceId);
69
80
  };
70
- }, [displaySizes, flightId, ref, enabled, square, instanceId]);
81
+ }, [displaySizes, flightId, el, enabled, square, instanceId]);
71
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast-react",
3
- "version": "1.10.1",
3
+ "version": "1.10.3",
4
4
  "description": "React components and hooks for kerbcast camera feeds.",
5
5
  "license": "MIT",
6
6
  "author": "Jon Pepler",
@@ -45,7 +45,7 @@
45
45
  "access": "public"
46
46
  },
47
47
  "dependencies": {
48
- "@ksp-gonogo/kerbcast": "^1.10.1"
48
+ "@ksp-gonogo/kerbcast": "^1.10.3"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^18 || ^19",
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
3
- import { createClientDisplaySizes } from "./context";
3
+ import { createClientDisplaySizes, createClientSubscriptions } from "./context";
4
4
 
5
5
  /**
6
6
  * A client stub that only records reportDisplaySize calls. The registry is the
@@ -11,12 +11,19 @@ import { createClientDisplaySizes } from "./context";
11
11
  */
12
12
  function fakeClient() {
13
13
  const reports: Array<{ flightId: number; width: number; height: number }> = [];
14
+ const handlers = new Map<string, Array<() => void>>();
14
15
  const client = {
16
+ on: (event: string, h: () => void) => {
17
+ handlers.set(event, [...(handlers.get(event) ?? []), h]);
18
+ return () => {};
19
+ },
15
20
  reportDisplaySize: vi.fn(async (flightId: number, width: number, height: number) => {
16
21
  reports.push({ flightId, width, height });
17
22
  }),
18
23
  } as unknown as KerbcastClient;
19
- return { client, reports };
24
+ /** Simulate the control channel (re)opening on a fresh peer. */
25
+ const openControl = () => handlers.get("control-open")?.forEach((h) => h());
26
+ return { client, reports, openControl };
20
27
  }
21
28
 
22
29
  describe("createClientDisplaySizes", () => {
@@ -91,4 +98,114 @@ describe("createClientDisplaySizes", () => {
91
98
  reg.clear(99, "ghost");
92
99
  expect(reports).toEqual([]);
93
100
  });
101
+
102
+ describe("reconnect", () => {
103
+ it("re-sends an unchanged size when the control channel reopens", () => {
104
+ const { client, reports, openControl } = fakeClient();
105
+ const reg = createClientDisplaySizes(client);
106
+ reg.report(1, "a", 640, 360);
107
+ openControl();
108
+ expect(reports).toEqual([
109
+ { flightId: 1, width: 640, height: 360 },
110
+ { flightId: 1, width: 640, height: 360 },
111
+ ]);
112
+ });
113
+
114
+ it("re-sends the current MAX for every flightId with reporters", () => {
115
+ const { client, reports, openControl } = fakeClient();
116
+ const reg = createClientDisplaySizes(client);
117
+ reg.report(1, "small", 40, 40);
118
+ reg.report(1, "big", 512, 512);
119
+ reg.report(2, "a", 320, 180);
120
+ reg.report(3, "gone", 99, 99);
121
+ reg.clear(3, "gone");
122
+ reports.length = 0;
123
+ openControl();
124
+ expect(reports).toEqual([
125
+ { flightId: 1, width: 512, height: 512 },
126
+ { flightId: 2, width: 320, height: 180 },
127
+ ]);
128
+ });
129
+
130
+ it("does not send duplicates while connected", () => {
131
+ const { client, reports } = fakeClient();
132
+ const reg = createClientDisplaySizes(client);
133
+ reg.report(1, "a", 640, 360);
134
+ reg.report(1, "a", 640, 360);
135
+ reg.report(1, "b", 100, 100);
136
+ expect(reports).toHaveLength(1);
137
+ });
138
+
139
+ it("keeps de-duplicating after a reconnect resend", () => {
140
+ const { client, reports, openControl } = fakeClient();
141
+ const reg = createClientDisplaySizes(client);
142
+ reg.report(1, "a", 640, 360);
143
+ openControl();
144
+ reg.report(1, "a", 640, 360);
145
+ expect(reports).toHaveLength(2);
146
+ });
147
+
148
+ it("sends nothing on reconnect when no reporters exist", () => {
149
+ const { client, reports, openControl } = fakeClient();
150
+ createClientDisplaySizes(client);
151
+ openControl();
152
+ expect(reports).toEqual([]);
153
+ });
154
+ });
155
+ });
156
+
157
+ describe("createClientSubscriptions reconnect", () => {
158
+ function subsClient() {
159
+ const calls: string[] = [];
160
+ const handlers: Array<() => void> = [];
161
+ const client = {
162
+ on: (_event: string, h: () => void) => {
163
+ handlers.push(h);
164
+ return () => {};
165
+ },
166
+ subscribe: vi.fn(async (id: number) => {
167
+ calls.push(`subscribe:${id}`);
168
+ }),
169
+ unsubscribe: vi.fn(async (id: number) => {
170
+ calls.push(`unsubscribe:${id}`);
171
+ }),
172
+ reportDisplaySize: vi.fn(async (id: number) => {
173
+ calls.push(`size:${id}`);
174
+ }),
175
+ } as unknown as KerbcastClient;
176
+ return { client, calls, openControl: () => handlers.forEach((h) => h()) };
177
+ }
178
+
179
+ it("re-subscribes every held flightId when the control channel reopens", () => {
180
+ const { client, calls, openControl } = subsClient();
181
+ const subs = createClientSubscriptions(client);
182
+ subs.acquire(1);
183
+ subs.acquire(1);
184
+ subs.acquire(2);
185
+ subs.acquire(3);
186
+ subs.release(3);
187
+ calls.length = 0;
188
+ openControl();
189
+ expect(calls).toEqual(["subscribe:1", "subscribe:2"]);
190
+ });
191
+
192
+ it("does not throw when a re-subscribe is rejected", async () => {
193
+ const { client, openControl } = subsClient();
194
+ (client.subscribe as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("pool full"));
195
+ const subs = createClientSubscriptions(client);
196
+ subs.acquire(1);
197
+ expect(() => openControl()).not.toThrow();
198
+ await Promise.resolve();
199
+ });
200
+
201
+ it("re-subscribes before the display-size resend", () => {
202
+ const { client, calls, openControl } = subsClient();
203
+ const subs = createClientSubscriptions(client);
204
+ const sizes = createClientDisplaySizes(client);
205
+ subs.acquire(1);
206
+ sizes.report(1, "a", 640, 360);
207
+ calls.length = 0;
208
+ openControl();
209
+ expect(calls).toEqual(["subscribe:1", "size:1"]);
210
+ });
94
211
  });
package/src/context.tsx CHANGED
@@ -26,6 +26,19 @@ export function createClientSubscriptions(
26
26
  client: KerbcastClient,
27
27
  ): KerbcastSubscriptions {
28
28
  const refcounts = new Map<number, number>();
29
+ /*
30
+ * A reconnect clears every slot binding on the client and the sidecar starts
31
+ * the new peer empty, so re-subscribe each camera still held. Listeners fire
32
+ * in registration order and the provider builds this before the display-size
33
+ * registry, so subscriptions go out first. The sidecar treats a repeat
34
+ * subscribe for a bound camera as a re-announce, and a rejection (for
35
+ * example a full slot pool) is swallowed like every other subscribe here.
36
+ */
37
+ client.on("control-open", () => {
38
+ for (const flightId of refcounts.keys()) {
39
+ client.subscribe(flightId).catch(() => {});
40
+ }
41
+ });
29
42
  return {
30
43
  acquire(flightId: number): void {
31
44
  const count = (refcounts.get(flightId) ?? 0) + 1;
@@ -99,6 +112,17 @@ export function createClientDisplaySizes(
99
112
  client.reportDisplaySize(flightId, maxW, maxH).catch(() => {});
100
113
  }
101
114
 
115
+ /*
116
+ * The sidecar forgets a peer's display sizes when the peer leaves, so after
117
+ * a reconnect nothing it holds matches lastSent. Re-send the current MAX for
118
+ * every camera that still has reporters, once the new control channel is
119
+ * open and can take messages.
120
+ */
121
+ client.on("control-open", () => {
122
+ lastSent.clear();
123
+ for (const flightId of [...perCamera.keys()]) recompute(flightId);
124
+ });
125
+
102
126
  return {
103
127
  report(flightId: number, instanceId: string, width: number, height: number): void {
104
128
  let instances = perCamera.get(flightId);
@@ -46,7 +46,7 @@ function fakeClient(): KerbcastClient {
46
46
  const controller = new RecordingController(new FakeRecordingClient(), {
47
47
  loadTrimmer: () => Promise.resolve(null),
48
48
  });
49
- return { recording: controller } as unknown as KerbcastClient;
49
+ return { recording: controller, on: () => () => {} } as unknown as KerbcastClient;
50
50
  }
51
51
 
52
52
  let storeA: RecordingsStore | undefined;
@@ -51,7 +51,7 @@ function fakeClient(): KerbcastClient {
51
51
  const controller = new RecordingController(new FakeRecordingClient(), {
52
52
  loadTrimmer: () => Promise.resolve(null),
53
53
  });
54
- return { recording: controller } as unknown as KerbcastClient;
54
+ return { recording: controller, on: () => () => {} } as unknown as KerbcastClient;
55
55
  }
56
56
 
57
57
  let store: RecordingsStore | undefined;
@@ -31,7 +31,7 @@ function recordingRegistry() {
31
31
  return { displaySizes, reports, clears };
32
32
  }
33
33
 
34
- const fakeClient = {} as unknown as KerbcastClient;
34
+ const fakeClient = { on: () => () => {} } as unknown as KerbcastClient;
35
35
 
36
36
  function fireResize(width: number, height: number) {
37
37
  act(() => {
@@ -56,6 +56,12 @@ function Harness({
56
56
  return <div ref={ref} data-testid="feed" />;
57
57
  }
58
58
 
59
+ function LateHarness({ show }: { show: boolean }) {
60
+ const ref = useRef<HTMLDivElement>(null);
61
+ useReportDisplaySize(7, ref);
62
+ return show ? <div ref={ref} data-testid="late" /> : null;
63
+ }
64
+
59
65
  beforeEach(() => {
60
66
  vi.useFakeTimers();
61
67
  globalThis.ResizeObserver = class {
@@ -163,4 +169,18 @@ describe("useReportDisplaySize", () => {
163
169
  expect(resizeCallback).toBeUndefined();
164
170
  expect(reports).toEqual([]);
165
171
  });
172
+
173
+ it("observes an element that appears after mount", () => {
174
+ const { displaySizes, reports } = recordingRegistry();
175
+ const view = (show: boolean) => (
176
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
177
+ <LateHarness show={show} />
178
+ </KerbcastProvider>
179
+ );
180
+ const { rerender } = render(view(false));
181
+ expect(resizeCallback).toBeUndefined();
182
+ rerender(view(true));
183
+ fireResize(40, 30);
184
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 32 }]);
185
+ });
166
186
  });
@@ -1,4 +1,4 @@
1
- import { useEffect, useId } from "react";
1
+ import { useEffect, useId, useLayoutEffect, useState } from "react";
2
2
  import { useKerbcastDisplaySizes } from "../context";
3
3
 
4
4
  /*
@@ -35,7 +35,8 @@ export interface ReportDisplaySizeOptions {
35
35
  * Self-measure a feed element and drive the client's per-consumer
36
36
  * `reportDisplaySize` (via the context display-size registry, which collapses
37
37
  * multiple feeds of the same camera to one MAX report). Attach `ref` to the
38
- * element whose rendered pixel box is the display size. Reporting follows the
38
+ * element whose rendered pixel box is the display size; an element that
39
+ * appears or changes after mount is picked up on the host's next render. Reporting follows the
39
40
  * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
40
41
  * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
41
42
  * server-side clear.
@@ -48,9 +49,19 @@ export function useReportDisplaySize(
48
49
  const displaySizes = useKerbcastDisplaySizes();
49
50
  const instanceId = useId();
50
51
 
52
+ /*
53
+ * Track the element behind the ref as state so an element that mounts or is
54
+ * swapped after the first render (conditional render, a feed that appears
55
+ * once a stream arrives) is observed. Checked after every commit of the
56
+ * host component; the state only changes when the element does.
57
+ */
58
+ const [el, setEl] = useState<Element | null>(null);
59
+ useLayoutEffect(() => {
60
+ if (ref.current !== el) setEl(ref.current);
61
+ });
62
+
51
63
  useEffect(() => {
52
64
  if (flightId === null || !enabled) return;
53
- const el = ref.current;
54
65
  if (!el) return;
55
66
 
56
67
  let timer: ReturnType<typeof setTimeout> | null = null;
@@ -87,5 +98,5 @@ export function useReportDisplaySize(
87
98
  if (timer !== null) clearTimeout(timer);
88
99
  displaySizes.clear(flightId, instanceId);
89
100
  };
90
- }, [displaySizes, flightId, ref, enabled, square, instanceId]);
101
+ }, [displaySizes, flightId, el, enabled, square, instanceId]);
91
102
  }