@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,413 @@
1
+ /// <reference path="../react-reconciler.d.ts" />
2
+ import type { ReactNode } from "react";
3
+ import createReconciler from "react-reconciler";
4
+ import { DefaultEventPriority } from "react-reconciler/constants";
5
+
6
+ import {
7
+ ROOT,
8
+ type RemoteOp,
9
+ type RemoteProps,
10
+ } from "../../core/constants/remote-op.const";
11
+
12
+ /**
13
+ * Runs a consumer's React component in the app, and reports what it drew.
14
+ *
15
+ * A component cannot be sent to the panel: it is a function, its closure lives in this engine, and
16
+ * only data crosses the debugger channel. So React runs here instead, against a renderer whose
17
+ * "DOM" is a list of numbered nodes, and the panel builds real elements from the changes that
18
+ * arrive. Hooks, effects, context and every other React feature work, because this is React.
19
+ *
20
+ * What does not work is anything that reaches for a real element: a `ref` gets one of these
21
+ * stand-ins, so a canvas, a measurement or a third-party DOM widget has nothing to hold. Those
22
+ * belong in a `page`, which is built for the browser and runs there.
23
+ */
24
+
25
+ type Instance = {
26
+ id: number;
27
+ type: string;
28
+ children: Instance[];
29
+ props: RemoteProps;
30
+ text?: string;
31
+ };
32
+
33
+ /** The app's end: React commits here, and the changes go out. */
34
+ export type RemoteSender = {
35
+ /** Draws `element`, or unmounts when given null. */
36
+ render: (element: ReactNode) => void;
37
+ /** Every op needed to build what is currently drawn, for a panel that just opened. */
38
+ replay: () => RemoteOp[];
39
+ /** Runs the function a `create`/`update` swapped out, if it is still the current one. */
40
+ /** `payload` is the arguments the panel's callback was called with. */
41
+ dispatch: (handler: string, payload: unknown) => void;
42
+ };
43
+
44
+ export function createRemoteSender(
45
+ emit: (ops: RemoteOp[]) => void,
46
+ ): RemoteSender {
47
+ const handlers = new Map<string, (...args: unknown[]) => void>();
48
+ let pending: RemoteOp[] = [];
49
+ let nextId = ROOT + 1;
50
+
51
+ const root: Instance = { id: ROOT, type: "#root", children: [], props: {} };
52
+
53
+ /**
54
+ * Strips what cannot be sent: `children` is React's own bookkeeping and the panel builds the tree
55
+ * from the ops instead, and a function is swapped for a name the panel can call back with.
56
+ */
57
+ const sendable = (id: number, props: RemoteProps): RemoteProps => {
58
+ const out: RemoteProps = {};
59
+
60
+ for (const [key, value] of Object.entries(props)) {
61
+ if (LOCAL_ONLY.has(key)) continue;
62
+
63
+ if (typeof value === "function") {
64
+ const name = `${id}:${key}`;
65
+ handlers.set(name, value as (payload: unknown) => void);
66
+ out[key] = { handler: name };
67
+ continue;
68
+ }
69
+
70
+ // Anything else has to survive JSON, and a value that does not would arrive as null and be
71
+ // applied as one, which is worse than never sending it.
72
+ if (value === undefined || typeof value === "symbol") continue;
73
+ out[key] = value;
74
+ }
75
+
76
+ return out;
77
+ };
78
+
79
+ /**
80
+ * What changed, including what went.
81
+ *
82
+ * A prop React dropped has to be named as gone rather than left out. The panel applies what it is
83
+ * handed and touches nothing else, so a key that merely stops appearing would leave the old value
84
+ * on the element for the rest of the session.
85
+ */
86
+ const changed = (
87
+ id: number,
88
+ prev: RemoteProps,
89
+ next: RemoteProps,
90
+ ): RemoteProps => {
91
+ const out = sendable(id, next);
92
+
93
+ for (const key of Object.keys(prev)) {
94
+ if (key in out || LOCAL_ONLY.has(key)) continue;
95
+ out[key] = null;
96
+ }
97
+
98
+ return out;
99
+ };
100
+
101
+ // One reconciler per sender, not one shared. React commits long after `render` returns, when an
102
+ // effect or a `setState` fires, so a shared one would have to be told which tab it was working for
103
+ // at a moment nothing is in a position to tell it.
104
+ const append = (parent: Instance, child: Instance): void => {
105
+ parent.children.push(child);
106
+ pending.push({ op: "append", parent: parent.id, child: child.id });
107
+ };
108
+
109
+ const insert = (
110
+ parent: Instance,
111
+ child: Instance,
112
+ before: Instance,
113
+ ): void => {
114
+ const at = parent.children.indexOf(before);
115
+ parent.children.splice(at < 0 ? parent.children.length : at, 0, child);
116
+ pending.push({
117
+ op: "insert",
118
+ parent: parent.id,
119
+ child: child.id,
120
+ before: before.id,
121
+ });
122
+ };
123
+
124
+ const remove = (parent: Instance, child: Instance): void => {
125
+ const at = parent.children.indexOf(child);
126
+ if (at >= 0) parent.children.splice(at, 1);
127
+ forget(child);
128
+ pending.push({ op: "remove", parent: parent.id, child: child.id });
129
+ };
130
+
131
+ const host: Record<string, unknown> = {
132
+ ...staticHostConfig(),
133
+
134
+ createInstance(type: string, props: RemoteProps): Instance {
135
+ const instance: Instance = {
136
+ id: nextId++,
137
+ type,
138
+ children: [],
139
+ props,
140
+ ...standIn(),
141
+ };
142
+ pending.push({
143
+ op: "create",
144
+ id: instance.id,
145
+ type,
146
+ props: sendable(instance.id, props),
147
+ });
148
+ return instance;
149
+ },
150
+
151
+ createTextInstance(text: string): Instance {
152
+ const instance: Instance = {
153
+ id: nextId++,
154
+ type: "#text",
155
+ children: [],
156
+ props: {},
157
+ text,
158
+ };
159
+ pending.push({ op: "text", id: instance.id, text });
160
+ return instance;
161
+ },
162
+
163
+ // A container is just another parent here, so the four "…Container" variants are the same three
164
+ // functions under a second name.
165
+ appendInitialChild: append,
166
+ appendChild: append,
167
+ appendChildToContainer: append,
168
+ insertBefore: insert,
169
+ insertInContainerBefore: insert,
170
+ removeChild: remove,
171
+ removeChildFromContainer: remove,
172
+
173
+ commitUpdate(
174
+ instance: Instance,
175
+ _type: string,
176
+ prev: RemoteProps,
177
+ next: RemoteProps,
178
+ ): void {
179
+ pending.push({
180
+ op: "update",
181
+ id: instance.id,
182
+ props: changed(instance.id, prev, next),
183
+ });
184
+ instance.props = next;
185
+ },
186
+
187
+ commitTextUpdate(instance: Instance, _prev: string, next: string): void {
188
+ instance.text = next;
189
+ pending.push({ op: "retext", id: instance.id, text: next });
190
+ },
191
+
192
+ clearContainer(container: Instance): void {
193
+ container.children = [];
194
+ pending.push({ op: "clear" });
195
+ },
196
+
197
+ // One message per commit rather than one per mutation, so a render that moves twenty nodes
198
+ // crosses the debugger connection once.
199
+ resetAfterCommit(): void {
200
+ if (pending.length === 0) return;
201
+ const ops = pending;
202
+ pending = [];
203
+ emit(ops);
204
+ },
205
+ };
206
+
207
+ /** A removed node's handlers would otherwise keep answering for a button that is gone. */
208
+ const forget = (instance: Instance): void => {
209
+ for (const key of handlers.keys()) {
210
+ if (key.startsWith(`${instance.id}:`)) handlers.delete(key);
211
+ }
212
+ for (const child of instance.children) forget(child);
213
+ };
214
+
215
+ const reconciler = createReconciler(host);
216
+
217
+ const container = reconciler.createContainer(
218
+ root,
219
+ 0,
220
+ null,
221
+ false,
222
+ null,
223
+ "",
224
+ (error: unknown) => {
225
+ console.error("[devtools] a tab component failed", error);
226
+ },
227
+ null,
228
+ null,
229
+ );
230
+
231
+ return {
232
+ render(element) {
233
+ reconciler.updateContainer(element, container, null, null);
234
+ },
235
+
236
+ replay() {
237
+ const ops: RemoteOp[] = [{ op: "clear" }];
238
+
239
+ const walk = (instance: Instance): void => {
240
+ for (const child of instance.children) {
241
+ if (child.text !== undefined) {
242
+ ops.push({ op: "text", id: child.id, text: child.text });
243
+ } else {
244
+ ops.push({
245
+ op: "create",
246
+ id: child.id,
247
+ type: child.type,
248
+ props: sendable(child.id, child.props),
249
+ });
250
+ }
251
+ walk(child);
252
+ ops.push({ op: "append", parent: instance.id, child: child.id });
253
+ }
254
+ };
255
+
256
+ walk(root);
257
+ return ops;
258
+ },
259
+
260
+ dispatch(handler, payload) {
261
+ // The panel sends what the callback was called with, so this calls it with the same. An older
262
+ // panel sent one event and nothing else, which arrives here as a single argument either way.
263
+ const args = Array.isArray(payload) ? payload : [payload];
264
+ handlers.get(handler)?.(...args.map(asEvent));
265
+ },
266
+ };
267
+ }
268
+
269
+ /**
270
+ * What a `ref` gets, and why it answers at all.
271
+ *
272
+ * There is no element on this side, so a ref holds one of these. It used to hold only the node, and
273
+ * React Native's own components call methods on a ref as a matter of course: `TextInput` focuses
274
+ * one, `ScrollView` scrolls one, anything animated sets props on one. Each of those was a
275
+ * `TypeError: undefined is not a function` the moment somebody used the component the ordinary way.
276
+ *
277
+ * So they are here, and they do nothing. A measurement answers zeroes rather than refusing, because
278
+ * a layout that reads one wants a number and not a crash. What cannot be faked is a command: React
279
+ * Native sends those to a native view by handle, sees this is not one, and says so. That warning is
280
+ * the honest report of a thing this package cannot do.
281
+ */
282
+ function standIn(): Record<string, unknown> {
283
+ const nothing = (): void => undefined;
284
+
285
+ return {
286
+ focus: nothing,
287
+ blur: nothing,
288
+ setNativeProps: nothing,
289
+ // Six zeroes: x, y, width, height, pageX, pageY.
290
+ measure: (back: (...box: number[]) => void) => back(0, 0, 0, 0, 0, 0),
291
+ measureInWindow: (back: (...box: number[]) => void) => back(0, 0, 0, 0),
292
+ measureLayout: (_relative: unknown, back: (...box: number[]) => void) =>
293
+ back(0, 0, 0, 0),
294
+ };
295
+ }
296
+
297
+ /** React's own bookkeeping, or a thing that cannot cross. Never sent, never named as gone. */
298
+ const LOCAL_ONLY = new Set(["children", "ref", "key"]);
299
+
300
+ /**
301
+ * What a handler is handed in place of the event.
302
+ *
303
+ * The event itself cannot cross, so the panel sends a description of it and this puts back the two
304
+ * methods every other handler reaches for. They do nothing: the browser finished with the event
305
+ * before this runs, and there is nothing left to cancel.
306
+ *
307
+ * A handler gets `type`, `key`, and `value`/`checked` on both `target` and `currentTarget`. That is
308
+ * all of it. **Everything else is missing**, including `preventDefault` having any effect,
309
+ * `relatedTarget`, coordinates, modifier flags, `dataTransfer`, and the element itself. React's
310
+ * event types still describe the full event, so TypeScript will not stop you reading a field that
311
+ * arrives undefined.
312
+ *
313
+ * `currentTarget` is put back as the same object as `target`, which is how the panel sent it. JSON
314
+ * has no notion of two names for one object, so it writes the thing twice and parsing gives two.
315
+ * React Native's `Pressability` compares them to decide whether a click was really meant for the
316
+ * element it is on, and two equal-looking objects are not equal, so every press on a `Pressable`
317
+ * or a `TouchableOpacity` was being dropped at that line.
318
+ */
319
+ function asEvent(payload: unknown): unknown {
320
+ if (typeof payload !== "object" || payload === null) return payload;
321
+ // A plain value that happens to be an object, a style or a row, is an argument and not an event.
322
+ if (!("currentTarget" in payload) && !("nativeEvent" in payload))
323
+ return payload;
324
+
325
+ const event: Record<string, unknown> = {
326
+ preventDefault: () => undefined,
327
+ stopPropagation: () => undefined,
328
+ ...payload,
329
+ };
330
+
331
+ if (event.target != null && event.currentTarget != null)
332
+ event.currentTarget = event.target;
333
+
334
+ return event;
335
+ }
336
+
337
+ /** Everything React asks for that has nothing to do with this package. */
338
+ function staticHostConfig(): Record<string, unknown> {
339
+ let priority = DefaultEventPriority;
340
+
341
+ return {
342
+ supportsMutation: true,
343
+ supportsPersistence: false,
344
+ supportsHydration: false,
345
+ supportsResources: false,
346
+ supportsSingletons: false,
347
+ supportsTestSelectors: false,
348
+ supportsMicrotasks: true,
349
+ isPrimaryRenderer: true,
350
+ warnsIfNotActing: false,
351
+ rendererPackageName: "@axonpack/react-native-devtools-tab",
352
+ rendererVersion: "0",
353
+
354
+ noTimeout: -1,
355
+ scheduleTimeout: setTimeout,
356
+ cancelTimeout: clearTimeout,
357
+ scheduleMicrotask: queueMicrotask,
358
+
359
+ finalizeInitialChildren: () => false,
360
+ shouldSetTextContent: () => false,
361
+ // Asserted to be non-null by React, so it is an object rather than the null it might look like.
362
+ getRootHostContext: () => ({}),
363
+ getChildHostContext: (parent: unknown) => parent,
364
+ getPublicInstance: (instance: unknown) => instance,
365
+ prepareForCommit: () => null,
366
+ preparePortalMount: () => undefined,
367
+ detachDeletedInstance: () => undefined,
368
+ resetTextContent: () => undefined,
369
+ hideInstance: () => undefined,
370
+ unhideInstance: () => undefined,
371
+ hideTextInstance: () => undefined,
372
+ unhideTextInstance: () => undefined,
373
+
374
+ getInstanceFromNode: () => null,
375
+ getInstanceFromScope: () => null,
376
+ beforeActiveInstanceBlur: () => undefined,
377
+ afterActiveInstanceBlur: () => undefined,
378
+ prepareScopeUpdate: () => undefined,
379
+
380
+ setCurrentUpdatePriority: (next: number) => {
381
+ priority = next;
382
+ },
383
+ getCurrentUpdatePriority: () => priority,
384
+ resolveUpdatePriority: () => priority || DefaultEventPriority,
385
+ shouldAttemptEagerTransition: () => false,
386
+ requestPostPaintCallback: () => undefined,
387
+ trackSchedulerEvent: () => undefined,
388
+ resolveEventType: () => null,
389
+ resolveEventTimeStamp: () => -1.1,
390
+
391
+ maySuspendCommit: () => false,
392
+ maySuspendCommitInSyncRender: () => false,
393
+ maySuspendCommitOnUpdate: () => false,
394
+ preloadInstance: () => true,
395
+ startSuspendingCommit: () => undefined,
396
+ suspendInstance: () => undefined,
397
+ suspendOnActiveViewTransition: () => undefined,
398
+ waitForCommitToBeReady: () => null,
399
+ shouldDeleteUnhydratedTailInstances: () => false,
400
+ isSingletonScope: () => false,
401
+ NotPendingTransition: null,
402
+ HostTransitionContext: {
403
+ $$typeof: Symbol.for("react.context"),
404
+ Provider: null,
405
+ Consumer: null,
406
+ _currentValue: null,
407
+ _currentValue2: null,
408
+ _threadCount: 0,
409
+ },
410
+ resetFormInstance: () => undefined,
411
+ bindToConsole: () => () => undefined,
412
+ };
413
+ }
package/src/index.ts ADDED
@@ -0,0 +1,175 @@
1
+ import { createElement, type ComponentType } from "react";
2
+
3
+ import { DEVTOOLS_ID, DEVTOOLS_TABS } from "./core/constants/devtools.const";
4
+ import {
5
+ ACTION,
6
+ HELLO,
7
+ MUTATE,
8
+ REGISTER,
9
+ } from "./core/constants/message.const";
10
+ import type {
11
+ TabAction,
12
+ TabMutation,
13
+ TabRegistration,
14
+ } from "./core/constants/message.const";
15
+ import { createMessageChannel } from "./core/services/message-channel.service";
16
+ import { createTabChannel } from "./core/services/tab-channel.service";
17
+ import { TabFrame } from "./device/components/tab-frame.component";
18
+ import { connectFuseboxTransport } from "./device/services/fusebox-transport.service";
19
+ import {
20
+ createRemoteSender,
21
+ type RemoteSender,
22
+ } from "./device/services/remote-sender.service";
23
+
24
+ const channel = createMessageChannel();
25
+
26
+ void connectFuseboxTransport(DEVTOOLS_ID).then((transport) => {
27
+ if (transport) channel.attach(transport);
28
+ });
29
+
30
+ /** Read by the frontend on connect, so a panel is built without the app being asked. */
31
+ const tabs: (TabRegistration & { id: string })[] = [];
32
+ (globalThis as Record<string, unknown>)[DEVTOOLS_TABS] = tabs;
33
+
34
+ const taken = new Set<string>();
35
+
36
+ /**
37
+ * A tab names itself, from the name it already has.
38
+ *
39
+ * Nobody writes one, so nobody can collide with another package's tab or repeat their own. It is
40
+ * derived rather than random because it is also the DevTools panel's own id: the frontend keeps a
41
+ * panel per id, so a fresh one on every reload would leave the dead tab in the strip beside the new
42
+ * one. Derived, a reload lands on the tab that is already open.
43
+ */
44
+ function idFor(name: string): string {
45
+ const base =
46
+ name
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9]+/g, "-")
49
+ .replace(/^-|-$/g, "") || "tab";
50
+
51
+ let id = base;
52
+ for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
53
+ taken.add(id);
54
+
55
+ return id;
56
+ }
57
+
58
+ export type TabOptions = {
59
+ /** The label in the DevTools tab strip, and what the tab's id is built from. */
60
+ name: string;
61
+ /**
62
+ * A symbol shown after the tab's name. Defaults to the Axonpack mark.
63
+ *
64
+ * Text rather than an image: React Native DevTools' own icon slots take an element, and every way
65
+ * of putting one there loses its drawing. Any character works, so an emoji does too.
66
+ */
67
+ icon?: string;
68
+ /**
69
+ * What the tab draws, under the bar this package puts above it.
70
+ *
71
+ * Ordinary React: hooks, effects, context, any component it composes. It runs **in the app**, not
72
+ * in the panel, against a renderer that reports what it drew instead of touching a DOM, and the
73
+ * panel builds the real elements from that. So `useState` redraws the tab, and a handler runs
74
+ * here, where the app's own state already is.
75
+ *
76
+ * The JSX can be `div` and `button`, because the elements are made at the other end, in a browser.
77
+ * It can equally be `View`, `Text` and `Pressable`: those reach the panel as the host elements
78
+ * React Native compiled them to, and the panel draws them with react-native-web. Layout, text and
79
+ * presses cross. What does not is behaviour that lives in native code rather than in the
80
+ * JavaScript, so `SafeAreaView` lays out with no insets and native `Animated` does not move.
81
+ *
82
+ * What it cannot do is touch a real element, because there isn't one on this side. A `ref` holds a
83
+ * stand-in, so a canvas, a measurement or a DOM library has nothing to work with, and an event
84
+ * arrives as a description of itself rather than the event.
85
+ */
86
+ component: ComponentType;
87
+ };
88
+
89
+ /**
90
+ * The app's side of React Native DevTools.
91
+ *
92
+ * A single object, because an app has exactly one debugger connection: there is nothing for a
93
+ * factory to vary and nothing to pass around. Usable immediately, too. Anything sent before somebody
94
+ * opens DevTools is kept and flushed when they do, and a release build has no connection at all, so
95
+ * this stays quiet.
96
+ *
97
+ * ```tsx
98
+ * function Session() {
99
+ * const [user, setUser] = useState('nobody');
100
+ * useEffect(() => auth.onChange(setUser), []);
101
+ *
102
+ * return (
103
+ * <div>
104
+ * <p>signed in as {user}</p>
105
+ * <button onClick={() => auth.signOut()}>sign out</button>
106
+ * </div>
107
+ * );
108
+ * }
109
+ *
110
+ * ReactNativeDevtoolsPanel.registerTab({
111
+ * name: 'Session',
112
+ * component: Session,
113
+ * });
114
+ * ```
115
+ *
116
+ * A tab reaches the app by being part of it. Reading its state and calling into it are ordinary
117
+ * React, so there is nothing else on this object.
118
+ */
119
+ export type ReactNativeDevtoolsPanel = {
120
+ /** Adds a tab to React Native DevTools. Call it once per tab, as many times as you have tabs. */
121
+ registerTab: (options: TabOptions) => void;
122
+ };
123
+
124
+ function registerTab(options: TabOptions): void {
125
+ const id = idFor(options.name);
126
+ const tab = createTabChannel(channel, id);
127
+ let sender: RemoteSender | null = null;
128
+
129
+ const registration: TabRegistration = {
130
+ name: options.name,
131
+ icon: options.icon,
132
+ };
133
+ tabs.push({ id, ...registration });
134
+
135
+ // Still pushed, for a tab registered while somebody already has DevTools open. The frontend reads
136
+ // the list when it connects, so this is the only case it cannot cover. It is also how a page that
137
+ // is already open learns the app restarted, which is why nothing answers HELLO with it: a page
138
+ // asks again when one arrives, and the two would go round forever.
139
+ tab.send(REGISTER, registration);
140
+
141
+ // A panel opened after the app started has missed the registration, so it asks rather than
142
+ // waiting. Asking is also what makes reloading either side recover.
143
+ tab.onMessage(HELLO, () => {
144
+ if (sender) {
145
+ // Already drawn once, for a panel that has since gone. Replaying what it holds is what keeps
146
+ // the component's own state through a panel reload: it is never re-mounted.
147
+ tab.send(MUTATE, { ops: sender.replay() } satisfies TabMutation);
148
+ return;
149
+ }
150
+
151
+ // First look at this tab, so nothing has been rendered for it. Mounting only now is what keeps
152
+ // a release build free: there is no panel to ask, so a component never runs, its effects never
153
+ // start, and nothing it does costs anything.
154
+ sender = createRemoteSender((ops) =>
155
+ tab.send(MUTATE, { ops } satisfies TabMutation),
156
+ );
157
+ sender.render(
158
+ createElement(TabFrame, {
159
+ name: options.name,
160
+ component: options.component,
161
+ }),
162
+ );
163
+ });
164
+
165
+ // A handler is named by where it sits in the tree rather than by a name somebody chose, so it
166
+ // needs no registering and two tabs cannot collide.
167
+ tab.onMessage(ACTION, (payload) => {
168
+ const event = payload as TabAction;
169
+ sender?.dispatch(event.action, event.payload);
170
+ });
171
+ }
172
+
173
+ export const ReactNativeDevtoolsPanel: ReactNativeDevtoolsPanel = {
174
+ registerTab,
175
+ };