@axonpack/react-native-devtools-tab 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.
@@ -0,0 +1,461 @@
1
+ import { createElement, useState, useSyncExternalStore } from "react";
2
+
3
+ import { expect, test } from "bun:test";
4
+ import { JSDOM } from "jsdom";
5
+
6
+ import { createRemoteSender } from "../remote-sender.service";
7
+
8
+ /**
9
+ * Both halves at once: React in one place, the panel's React in another, with the ops carried by
10
+ * hand instead of by the debugger channel. That is the only part these tests leave out.
11
+ *
12
+ * The window is made before the panel's side is imported, because react-native-web looks for a
13
+ * document as it loads and gives up on its stylesheet if there is not one yet.
14
+ */
15
+ const dom = new JSDOM("<!doctype html><html><body></body></html>");
16
+ Object.assign(globalThis, {
17
+ window: dom.window,
18
+ document: dom.window.document,
19
+ navigator: dom.window.navigator,
20
+ });
21
+
22
+ // A browser has these on the global, and both react-dom and react-native-web reach for them by
23
+ // bare name. A real page and the built bundle have them; this file has to put them there itself.
24
+ for (const name of [
25
+ "ShadowRoot",
26
+ "Node",
27
+ "Element",
28
+ "HTMLElement",
29
+ "Event",
30
+ "MouseEvent",
31
+ "CSSStyleSheet",
32
+ "MutationObserver",
33
+ "getComputedStyle",
34
+ "requestAnimationFrame",
35
+ "cancelAnimationFrame",
36
+ ]) {
37
+ (globalThis as Record<string, unknown>)[name] = (
38
+ dom.window as unknown as Record<string, unknown>
39
+ )[name];
40
+ }
41
+
42
+ const { createRoot } = await import("react-dom/client");
43
+ const { createRemoteReceiver } =
44
+ await import("../../../renderer/services/remote-receiver.service");
45
+ const { RemoteTree } =
46
+ await import("../../../renderer/components/remote-tree.component");
47
+ const { TabFrame } = await import("../../components/tab-frame.component");
48
+
49
+ function mount() {
50
+ const container = dom.window.document.createElement("div");
51
+ dom.window.document.body.appendChild(container);
52
+
53
+ const pressed: { handler: string; payload: unknown }[] = [];
54
+ const receiver = createRemoteReceiver();
55
+
56
+ // Through JSON, because that is what the debugger connection carries. Handing objects straight
57
+ // across made the tests kinder than the wire: identity survived here and never survives there.
58
+ const wire = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
59
+
60
+ createRoot(container).render(
61
+ createElement(RemoteTree, {
62
+ receiver,
63
+ send: (handler: string, payload: unknown) =>
64
+ pressed.push({ handler, payload: wire(payload) }),
65
+ }),
66
+ );
67
+
68
+ const sender = createRemoteSender((ops) => receiver.apply(wire(ops)));
69
+
70
+ return {
71
+ container,
72
+ receiver,
73
+ sender,
74
+ pressed,
75
+ // Bubbling, because React listens at the root it was given rather than on each element.
76
+ click: (element: Element) =>
77
+ element.dispatchEvent(
78
+ new dom.window.MouseEvent("click", { bubbles: true }),
79
+ ),
80
+ };
81
+ }
82
+
83
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
84
+
85
+ test("a component's hooks run in the app and its DOM appears in the panel", async () => {
86
+ const { container, sender, pressed, click } = mount();
87
+
88
+ function Panel() {
89
+ const [count, setCount] = useState(0);
90
+ return createElement(
91
+ "div",
92
+ { className: "wrap" },
93
+ createElement("span", null, `count ${count}`),
94
+ createElement("button", { onClick: () => setCount(count + 1) }, "more"),
95
+ );
96
+ }
97
+
98
+ sender.render(createElement(Panel));
99
+ await settle();
100
+
101
+ expect(container.querySelector("div.wrap")).not.toBeNull();
102
+ expect(container.textContent).toContain("count 0");
103
+
104
+ const button = container.querySelector("button")!;
105
+ click(button);
106
+ expect(pressed).toHaveLength(1);
107
+
108
+ // The panel cannot run the handler, so it sends back the name the prop was swapped for.
109
+ sender.dispatch(pressed[0].handler, pressed[0].payload);
110
+ await settle();
111
+ expect(container.textContent).toContain("count 1");
112
+
113
+ // The same button element, not a replacement: this is what keeps focus and the caret in an input
114
+ // while something above it re-renders.
115
+ expect(container.querySelector("button")).toBe(button);
116
+ });
117
+
118
+ test("a panel opening later is told the tree that is already there", async () => {
119
+ const { sender } = mount();
120
+
121
+ function Panel() {
122
+ return createElement("p", null, "late");
123
+ }
124
+
125
+ sender.render(createElement(Panel));
126
+ await settle();
127
+
128
+ // A second panel, with none of the earlier changes.
129
+ const second = mount();
130
+ second.receiver.apply(sender.replay());
131
+ await settle();
132
+
133
+ expect(second.container.textContent).toContain("late");
134
+ });
135
+
136
+ test("removing a node stops its handlers answering", async () => {
137
+ const { container, sender, pressed, click } = mount();
138
+ let calls = 0;
139
+
140
+ function Panel() {
141
+ const [shown, setShown] = useState(true);
142
+ return createElement(
143
+ "div",
144
+ null,
145
+ createElement("button", { onClick: () => setShown(false) }, "hide"),
146
+ shown
147
+ ? createElement("button", { onClick: () => (calls += 1) }, "count me")
148
+ : null,
149
+ );
150
+ }
151
+
152
+ sender.render(createElement(Panel));
153
+ await settle();
154
+
155
+ const counted = [...container.querySelectorAll("button")].find(
156
+ (element) => element.textContent === "count me",
157
+ )!;
158
+ const handler = pressed.length;
159
+ click(counted);
160
+ sender.dispatch(pressed[handler].handler, null);
161
+ expect(calls).toBe(1);
162
+
163
+ click([...container.querySelectorAll("button")][0]);
164
+ sender.dispatch(pressed[pressed.length - 1].handler, null);
165
+ await settle();
166
+
167
+ // Gone from the DOM, and the name it answered to no longer reaches anything.
168
+ expect(container.textContent).not.toContain("count me");
169
+ sender.dispatch(pressed[handler].handler, null);
170
+ expect(calls).toBe(1);
171
+ });
172
+
173
+ test("a tab follows the app's own store, and writes back to it", async () => {
174
+ const { container, sender, pressed, click } = mount();
175
+
176
+ // The app's state, as any state library would hold it.
177
+ let state = { requests: 0 };
178
+ const listeners = new Set<() => void>();
179
+ const store = {
180
+ subscribe: (listener: () => void) => {
181
+ listeners.add(listener);
182
+ return () => listeners.delete(listener);
183
+ },
184
+ snapshot: () => state,
185
+ bump: () => {
186
+ state = { requests: state.requests + 1 };
187
+ for (const listener of listeners) listener();
188
+ },
189
+ };
190
+
191
+ function Panel() {
192
+ const session = useSyncExternalStore(store.subscribe, store.snapshot);
193
+ return createElement(
194
+ "button",
195
+ { onClick: store.bump },
196
+ `requests ${session.requests}`,
197
+ );
198
+ }
199
+
200
+ sender.render(createElement(Panel));
201
+ await settle();
202
+ expect(container.textContent).toContain("requests 0");
203
+
204
+ // The app changes it. No message is sent: the tab is subscribed to the same object.
205
+ store.bump();
206
+ await settle();
207
+ expect(container.textContent).toContain("requests 1");
208
+
209
+ // The tab changes it, and the app sees it, because the handler runs on the app's side.
210
+ click(container.querySelector("button")!);
211
+ sender.dispatch(pressed.at(-1)!.handler, pressed.at(-1)!.payload);
212
+ await settle();
213
+ expect(store.snapshot().requests).toBe(2);
214
+ expect(container.textContent).toContain("requests 2");
215
+ });
216
+
217
+ test("a prop React stops rendering is taken off the element", async () => {
218
+ const { container, sender, pressed, click } = mount();
219
+
220
+ function Panel() {
221
+ const [busy, setBusy] = useState(true);
222
+ return createElement("button", {
223
+ onClick: () => setBusy(false),
224
+ title: busy ? "working" : undefined,
225
+ });
226
+ }
227
+
228
+ sender.render(createElement(Panel));
229
+ await settle();
230
+ expect(container.querySelector("button")!.getAttribute("title")).toBe(
231
+ "working",
232
+ );
233
+
234
+ click(container.querySelector("button")!);
235
+ sender.dispatch(pressed.at(-1)!.handler, pressed.at(-1)!.payload);
236
+ await settle();
237
+
238
+ // React sends only what changed, so a prop that went has to be named as gone rather than left out.
239
+ expect(container.querySelector("button")!.hasAttribute("title")).toBe(false);
240
+ });
241
+
242
+ test("every tab gets the library's bar, and its button renders the tab again", async () => {
243
+ const { container, sender, pressed, click } = mount();
244
+
245
+ let renders = 0;
246
+ function Body() {
247
+ renders++;
248
+ return createElement("p", null, "body");
249
+ }
250
+
251
+ sender.render(createElement(TabFrame, { name: "Session", component: Body }));
252
+ await settle();
253
+
254
+ expect(
255
+ container.querySelector("header.axonpack-tab-bar")?.textContent,
256
+ ).toContain("Session");
257
+ expect(renders).toBe(1);
258
+
259
+ click(container.querySelector("header button")!);
260
+ sender.dispatch(pressed[0].handler, pressed[0].payload);
261
+ await settle();
262
+
263
+ // The bar holds a loader up for a moment first, so the tab is not drawn again on this tick.
264
+ expect(renders).toBe(1);
265
+
266
+ for (let waited = 0; waited < 50 && renders < 2; waited++) await settle();
267
+
268
+ expect(renders).toBe(2);
269
+ });
270
+
271
+ test("a React Native tree is drawn by react-native-web, not translated", async () => {
272
+ const { container, sender, pressed, click } = mount();
273
+
274
+ let taps = 0;
275
+
276
+ /**
277
+ * What `<View style={styles.row}><Pressable onPress={…}><Text>Tap</Text></Pressable></View>`
278
+ * compiles to. Written out rather than imported, because React Native's source is Flow and does
279
+ * not run outside Metro; these are the host types and props it hands a renderer.
280
+ */
281
+ function Screen() {
282
+ return createElement(
283
+ "RCTView",
284
+ { style: { flexDirection: "row", gap: 8, padding: 12 } },
285
+ createElement(
286
+ "RCTView",
287
+ {
288
+ style: [{ backgroundColor: "#2b3040" }, { borderRadius: 8 }],
289
+ collapsable: false,
290
+ onClick: () => taps++,
291
+ onStartShouldSetResponder: () => true,
292
+ onResponderRelease: () => {},
293
+ },
294
+ createElement("RCTText", { style: { fontSize: 12 } }, "Tap"),
295
+ ),
296
+ );
297
+ }
298
+
299
+ sender.render(createElement(Screen));
300
+ await settle();
301
+
302
+ // `css-view` is react-native-web's own reset, which is the whole point: Yoga's defaults are its
303
+ // job rather than something this package writes out.
304
+ const row = container.firstElementChild as HTMLElement;
305
+ expect(row.className).toContain("css-view");
306
+ expect(row.style.flexDirection).toBe("row");
307
+ expect(row.style.paddingTop).toBe("12px");
308
+
309
+ const button = row.firstElementChild as HTMLElement;
310
+ expect(button.style.backgroundColor).toBe("rgb(43, 48, 64)");
311
+ // Normalised to the four corners, the way react-native-web normalises it on the web.
312
+ expect(button.style.borderTopLeftRadius).toBe("8px");
313
+ // A view manager instruction, dropped because it is not a prop a DOM element has.
314
+ expect(button.hasAttribute("collapsable")).toBe(false);
315
+
316
+ const label = button.firstElementChild as HTMLElement;
317
+ expect(label.className).toContain("css-text");
318
+ expect(label.getAttribute("dir")).toBe("auto");
319
+ expect(label.textContent).toBe("Tap");
320
+
321
+ // The press React Native already puts on the view, arriving as the DOM event the panel sends.
322
+ click(button);
323
+ const press = pressed.at(-1)!;
324
+ sender.dispatch(press.handler, press.payload);
325
+ await settle();
326
+
327
+ expect(taps).toBe(1);
328
+ });
329
+
330
+ test("clicking the label inside a pressable reaches the app's onPress", async () => {
331
+ const { container, sender, pressed, click } = mount();
332
+
333
+ let taps = 0;
334
+
335
+ // What RN's Pressable actually produces: onClick plus the responder props, and the user clicks
336
+ // the Text inside it rather than the view that carries the handler.
337
+ function Screen() {
338
+ return createElement(
339
+ "RCTView",
340
+ {
341
+ style: { padding: 10 },
342
+ onClick: () => taps++,
343
+ onStartShouldSetResponder: () => true,
344
+ onResponderGrant: () => {},
345
+ onResponderRelease: () => {},
346
+ onResponderTerminationRequest: () => true,
347
+ },
348
+ createElement("RCTText", null, "Increment"),
349
+ );
350
+ }
351
+
352
+ sender.render(createElement(Screen));
353
+ await settle();
354
+
355
+ const label = container.querySelector(".css-text-146c3p1") as HTMLElement;
356
+ expect(label.textContent).toBe("Increment");
357
+
358
+ click(label);
359
+ const press = pressed.at(-1);
360
+ if (press) sender.dispatch(press.handler, press.payload);
361
+ await settle();
362
+
363
+ // The responder props are React Native's own protocol and cannot answer across a wire, so the
364
+ // press rides on `onClick`, which Pressability sets at render time for exactly this reason.
365
+ expect(taps).toBe(1);
366
+ });
367
+
368
+ test("a handler comparing target with currentTarget sees one object, not two", async () => {
369
+ const { container, sender, pressed, click } = mount();
370
+
371
+ let pressedWith: { same: boolean } | null = null;
372
+
373
+ // What React Native's `Pressability` does first: a click whose target is not the element the
374
+ // handler sits on belongs to something nested, so it is ignored. Across JSON the two arrive as
375
+ // separate objects, which made that check reject every press a `Pressable` ever received.
376
+ function Screen() {
377
+ return createElement(
378
+ "RCTView",
379
+ {
380
+ onClick: (event: { target: unknown; currentTarget: unknown }) => {
381
+ pressedWith = { same: event.currentTarget === event.target };
382
+ },
383
+ },
384
+ createElement("RCTText", null, "Increment"),
385
+ );
386
+ }
387
+
388
+ sender.render(createElement(Screen));
389
+ await settle();
390
+
391
+ click(container.querySelector(".css-text-146c3p1") as HTMLElement);
392
+ const press = pressed.at(-1)!;
393
+ sender.dispatch(press.handler, press.payload);
394
+ await settle();
395
+
396
+ expect(pressedWith).toEqual({ same: true });
397
+ });
398
+
399
+ test("a callback the app passed through reaches it with its own argument", async () => {
400
+ const { container, sender, pressed } = mount();
401
+
402
+ let got: unknown = "untouched";
403
+
404
+ // What `<TextInput onChangeText={setValue}/>` leaves on the host element: React Native keeps
405
+ // `onChangeText` in the props it spreads, so the panel is the one calling it, with a string.
406
+ function Screen() {
407
+ return createElement("RCTSinglelineTextInputView", {
408
+ value: "",
409
+ onChangeText: (text: unknown) => {
410
+ got = text;
411
+ },
412
+ });
413
+ }
414
+
415
+ sender.render(createElement(Screen));
416
+ await settle();
417
+
418
+ const input = container.querySelector("input") as HTMLInputElement;
419
+ Object.getOwnPropertyDescriptor(
420
+ dom.window.HTMLInputElement.prototype,
421
+ "value",
422
+ )!.set!.call(input, "ada");
423
+ input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
424
+
425
+ const change = pressed.at(-1)!;
426
+ sender.dispatch(change.handler, change.payload);
427
+ await settle();
428
+
429
+ expect(got).toBe("ada");
430
+ });
431
+
432
+ test("a ref answers the methods React Native calls on a host instance", async () => {
433
+ const { sender } = mount();
434
+
435
+ let held: Record<string, unknown> | null = null;
436
+ const measured: number[] = [];
437
+
438
+ function Screen() {
439
+ return createElement("RCTSinglelineTextInputView", {
440
+ ref: (instance: Record<string, unknown> | null) => {
441
+ held = instance;
442
+ },
443
+ });
444
+ }
445
+
446
+ sender.render(createElement(Screen));
447
+ await settle();
448
+
449
+ // React Native's own components do all of these to a ref without asking whether they can.
450
+ const ref = held as unknown as Record<string, (...args: unknown[]) => void>;
451
+ expect(typeof ref.focus).toBe("function");
452
+ expect(typeof ref.blur).toBe("function");
453
+ expect(typeof ref.setNativeProps).toBe("function");
454
+
455
+ ref.focus();
456
+ ref.setNativeProps({ text: "ada" });
457
+ ref.measure((...box: unknown[]) => measured.push(...(box as number[])));
458
+
459
+ // Zeroes rather than a refusal, because a layout reading a measurement wants a number.
460
+ expect(measured).toEqual([0, 0, 0, 0, 0, 0]);
461
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * React Native installs this when a debugger attaches. It is the same channel React DevTools uses,
3
+ * multiplexed by a domain name, which is why every message carries one.
4
+ */
5
+ const DISPATCHER = "__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__";
6
+
7
+ type DomainChannel = {
8
+ name: string;
9
+ sendMessage: (message: unknown) => void;
10
+ onMessage: {
11
+ addEventListener: (listener: (message: unknown) => void) => void;
12
+ removeEventListener: (listener: (message: unknown) => void) => void;
13
+ };
14
+ };
15
+
16
+ type Dispatcher = {
17
+ BINDING_NAME: string;
18
+ initializeDomain: (domain: string) => DomainChannel;
19
+ onDomainInitialization: {
20
+ addEventListener: (listener: (domain: DomainChannel) => void) => void;
21
+ removeEventListener: (listener: (domain: DomainChannel) => void) => void;
22
+ };
23
+ };
24
+
25
+ function getDispatcher(): Dispatcher | undefined {
26
+ return (globalThis as Record<string, unknown>)[DISPATCHER] as
27
+ Dispatcher | undefined;
28
+ }
29
+
30
+ /**
31
+ * The binding only exists once the frontend has asked for it, which happens when the panel opens. So
32
+ * a domain is either already available or arrives later, and both have to be handled: an app started
33
+ * before the panel would otherwise never connect, and a panel opened first would never be answered.
34
+ */
35
+ function openDomain(
36
+ dispatcher: Dispatcher,
37
+ domain: string,
38
+ ): Promise<DomainChannel> {
39
+ return new Promise((resolve) => {
40
+ const handler = (opened: DomainChannel): void => {
41
+ if (opened.name !== domain) return;
42
+ dispatcher.onDomainInitialization.removeEventListener(handler);
43
+ // Resolving straight from this callback never settles under Hermes. Handing it to a timer
44
+ // does, which is the same workaround React Native's own devtools integration uses.
45
+ setTimeout(() => resolve(opened));
46
+ };
47
+ dispatcher.onDomainInitialization.addEventListener(handler);
48
+
49
+ // The panel may already be open, in which case the binding exists and nothing more will be
50
+ // emitted, so the domain has to be asked for. Registering the listener first means the emit this
51
+ // triggers is caught by the same path rather than needing a second one.
52
+ if (
53
+ (globalThis as Record<string, unknown>)[dispatcher.BINDING_NAME] != null
54
+ ) {
55
+ dispatcher.initializeDomain(domain);
56
+ }
57
+ });
58
+ }
59
+
60
+ export type FuseboxTransport = {
61
+ post: (envelope: unknown) => void;
62
+ subscribe: (deliver: (value: unknown) => void) => void;
63
+ };
64
+
65
+ /**
66
+ * Waits for the panel, then hands back a transport over React Native's own debugger connection.
67
+ *
68
+ * Resolves only once somebody opens React Native DevTools, which may be never, so callers should not
69
+ * block on it. Returns `null` where there is no dispatcher at all, which is every release build.
70
+ *
71
+ * **The channel has to survive re-initialisation.** `initializeDomain` builds a *new* domain object
72
+ * every time it is called and replaces the dispatcher's entry for that name, so a listener on the
73
+ * old one silently stops receiving. The panel calls it on every page load, and the app calls it too
74
+ * when it starts against an already-open panel, so holding a stale channel is the normal case rather
75
+ * than an edge one.
76
+ */
77
+ export async function connectFuseboxTransport(
78
+ domain: string,
79
+ ): Promise<FuseboxTransport | null> {
80
+ const dispatcher = getDispatcher();
81
+ if (!dispatcher) return null;
82
+
83
+ let channel = await openDomain(dispatcher, domain);
84
+ const delivers = new Set<(value: unknown) => void>();
85
+
86
+ const receive = (message: unknown): void => {
87
+ // Same timer workaround: a listener that starts a promise misbehaves when called directly.
88
+ setTimeout(() => {
89
+ for (const deliver of delivers) deliver(message);
90
+ });
91
+ };
92
+
93
+ channel.onMessage.addEventListener(receive);
94
+
95
+ dispatcher.onDomainInitialization.addEventListener((next) => {
96
+ if (next.name !== domain || next === channel) return;
97
+ channel.onMessage.removeEventListener(receive);
98
+ channel = next;
99
+ channel.onMessage.addEventListener(receive);
100
+ });
101
+
102
+ return {
103
+ post: (envelope) => channel.sendMessage(envelope),
104
+ subscribe: (deliver) => delivers.add(deliver),
105
+ };
106
+ }