@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 +24 -0
- package/dist/hooks/useReportDisplaySize.d.ts +2 -1
- package/dist/hooks/useReportDisplaySize.js +15 -4
- package/package.json +2 -2
- package/src/context.test.tsx +119 -2
- package/src/context.tsx +24 -0
- package/src/hooks/useRecordings.sharedState.test.tsx +1 -1
- package/src/hooks/useRecordings.test.tsx +1 -1
- package/src/hooks/useReportDisplaySize.test.tsx +21 -1
- package/src/hooks/useReportDisplaySize.ts +15 -4
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
|
|
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
|
|
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,
|
|
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.
|
|
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.
|
|
48
|
+
"@ksp-gonogo/kerbcast": "^1.10.3"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^18 || ^19",
|
package/src/context.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
101
|
+
}, [displaySizes, flightId, el, enabled, square, instanceId]);
|
|
91
102
|
}
|