@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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @axonpack/react-native-devtools-tab
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e1a59b0: - **Your own tab in React Native DevTools**, beside Console and Sources, from one call to `registerTab`
8
+ - **A tab is a React component** you write, with hooks, effects and anything it composes
9
+ - **It runs inside the app**, so it reads the app's own state and a button in it calls the app's own functions
10
+ - **No copy to keep in sync**: subscribe to a store and the tab redraws the moment that store changes
11
+ - **Write the tab in React Native or in HTML**, whichever suits it, both drawn by the panel
12
+ - **Name and symbol per tab**, and as many tabs as you register
13
+ - **A bar above every tab** with its name and a button that draws it again
14
+ - **One line in the Metro config** is the whole setup, with no fork of the DevTools frontend, no browser extension and no second window
15
+ - **Nothing ships to production**: guard the registrations with `__DEV__` and a release bundle carries none of it
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Md Asadujjaman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,359 @@
1
+ # @axonpack/react-native-devtools-tab
2
+
3
+ Put a tab of your own in React Native DevTools, and talk to the running app through it.
4
+
5
+ Your tab is part of the app, so it can use whatever the app can. Import your store and read it. When
6
+ that state changes, the tab updates with it, because it is the same state and not a copy of it. A
7
+ button in the tab calls your real function.
8
+
9
+ No fork of the DevTools frontend, no browser extension, no second window, and no bundler: you
10
+ describe what the tab shows and this renders it.
11
+
12
+ [Watch the demo](https://youtu.be/qMN6QO-qlF4)
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ bun add @axonpack/react-native-devtools-tab
18
+ ```
19
+
20
+ React 19.2 or newer is the one declared peer, and the Metro side wants Node 20 or newer. The tab
21
+ lives in React Native DevTools, so the app has to be one whose debugger is that rather than the old
22
+ Chrome remote debugger.
23
+
24
+ ## Set it up
25
+
26
+ Two files of your own, plus one line in the Metro config.
27
+
28
+ ### 1. Wrap the Metro config
29
+
30
+ This is what serves the tab to the DevTools page.
31
+
32
+ ```js
33
+ // metro.config.js
34
+ const { getDefaultConfig } = require("expo/metro-config");
35
+ const {
36
+ withReactNativeDevtoolsTab,
37
+ } = require("@axonpack/react-native-devtools-tab/metro");
38
+
39
+ module.exports = withReactNativeDevtoolsTab(getDefaultConfig(__dirname));
40
+ ```
41
+
42
+ A bare React Native app is the same line with its own default config, from
43
+ `@react-native/metro-config`.
44
+
45
+ ### 2. Write a tab and register it
46
+
47
+ A tab is any React component, and `registerTab` takes it. One file does both:
48
+
49
+ ```tsx
50
+ // devtools.tsx
51
+ import { useState } from "react";
52
+ import { ReactNativeDevtoolsPanel } from "@axonpack/react-native-devtools-tab";
53
+
54
+ function Session() {
55
+ const [count, setCount] = useState(0);
56
+
57
+ return (
58
+ <div style={{ padding: 12 }}>
59
+ <p>pressed {count} times</p>
60
+ <button onClick={() => setCount(count + 1)}>press</button>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ ReactNativeDevtoolsPanel.registerTab({
66
+ name: "Session",
67
+ icon: "\u269b",
68
+ component: Session,
69
+ });
70
+ ```
71
+
72
+ `name` is the label in the tab strip and what the tab's id is built from. `icon` is optional text
73
+ shown after it. `component` is what the tab draws. Call `registerTab` again in the same file for a
74
+ second tab, and split the components out into their own files whenever the file stops being
75
+ comfortable.
76
+
77
+ The JSX can be `div` and `button` because the elements are made in a browser, at the panel's end. It
78
+ can equally be React Native, which the panel draws with react-native-web:
79
+
80
+ ```tsx
81
+ import { Pressable, Text, View } from "react-native";
82
+
83
+ function Session() {
84
+ const [count, setCount] = useState(0);
85
+
86
+ return (
87
+ <View style={{ padding: 12, gap: 8 }}>
88
+ <Text>pressed {count} times</Text>
89
+ <Pressable onPress={() => setCount(count + 1)}>
90
+ <Text>press</Text>
91
+ </Pressable>
92
+ </View>
93
+ );
94
+ }
95
+ ```
96
+
97
+ Layout, text and presses come through. What does not is behaviour that lives in native code rather
98
+ than in the JavaScript, so `SafeAreaView` lays out with no insets and a natively driven `Animated`
99
+ sits still. [It runs in the app](#it-runs-in-the-app) is where that line sits.
100
+
101
+ ### 3. Pull that file in when the app starts
102
+
103
+ `devtools.tsx` is pulled in for its side effect, so it needs no export. Put the line in
104
+ `app/_layout.tsx` for Expo Router, or `App.tsx` otherwise:
105
+
106
+ ```tsx
107
+ // app/_layout.tsx
108
+ if (__DEV__) require("../devtools");
109
+
110
+ export default function Layout() {
111
+ // your app, unchanged
112
+ }
113
+ ```
114
+
115
+ Calling `registerTab` later works too, and so does a plain `import "../devtools"`. The `__DEV__`
116
+ guard is what keeps the tabs out of a release bundle. [In a release build](#in-a-release-build) has
117
+ the detail.
118
+
119
+ Restart Metro, open React Native DevTools, and the tab is in the strip beside Console and Sources.
120
+ If it is not, [when the tab does not appear](#when-the-tab-does-not-appear) lists what each failure
121
+ prints.
122
+
123
+ ## It runs in the app
124
+
125
+ React renders your component **here, in the app**, against a renderer that reports what it drew
126
+ instead of touching a DOM. The panel builds the real elements from the changes that arrive.
127
+
128
+ That is what lets `onClick` call `auth.signOut()` directly: the handler and the app's own state are
129
+ in the same place, so there is no message to write and no state to mirror. Hooks, effects and any
130
+ component you compose all work, because this is React.
131
+
132
+ It is also why the JSX can be `div` and `button`. The elements are made at the other end, in a
133
+ browser.
134
+
135
+ It can equally be `View`, `Text`, `Image`, `ScrollView` and `Pressable`. Those reach the panel as the
136
+ host elements React Native compiled them to (`RCTView`, `RCTText`), and the panel draws them with
137
+ **react-native-web**, the library that renders a React Native app in a browser. Yoga's defaults, the
138
+ units, the text layout and the stylesheet are its job, in the page where it has a real document.
139
+
140
+ What crosses is host output, so a component whose behaviour is native code does not get that
141
+ behaviour: `SafeAreaView` lays out but has no insets, an SVG path is an empty box, and `Animated`
142
+ driven natively sits at its first value. Layout, text and presses come through.
143
+
144
+ This is the trade the package makes on purpose. A tab could instead be built for the web by your own
145
+ Metro, which renders every React Native component perfectly, and it would then be a separate
146
+ JavaScript world that cannot see one value of the app's. Reading the running app is the point, so
147
+ the app is where a tab runs.
148
+
149
+ Changes cross as changes, not as a new tree, so the panel keeps the element it already had. An input
150
+ holds its caret and a scrolled list stays where it was while something above it re-renders.
151
+
152
+ ## Talking to the app
153
+
154
+ A tab's component runs in the app, so most of this is not communication at all.
155
+
156
+ - **Tab to app:** call it. `onClick={() => auth.signOut()}` is a function call, not a message.
157
+ - **App to tab:** whatever the app already uses to hold state. A tab is real React, so `useState`,
158
+ an emitter, Zustand, Jotai and MobX all work as they do anywhere else.
159
+
160
+ A store is a store. This one is plain React, with no library:
161
+
162
+ ```ts
163
+ // store.ts
164
+ import { useSyncExternalStore } from "react";
165
+
166
+ let value = { user: "nobody", requests: 0 };
167
+ const listeners = new Set<() => void>();
168
+
169
+ export const session = {
170
+ get: () => value,
171
+
172
+ set(next: typeof value) {
173
+ value = next;
174
+ for (const listener of listeners) listener();
175
+ },
176
+
177
+ use: () =>
178
+ useSyncExternalStore(
179
+ (listener) => {
180
+ listeners.add(listener);
181
+ return () => listeners.delete(listener);
182
+ },
183
+ () => value,
184
+ ),
185
+ };
186
+ ```
187
+
188
+ The tab imports it like any other file, because it is running in the app where that file already
189
+ lives:
190
+
191
+ ```tsx
192
+ // devtools.tsx
193
+ import { session } from "./store";
194
+
195
+ export default function Session() {
196
+ const { user, requests } = session.use();
197
+
198
+ return (
199
+ <div style={{ padding: 12 }}>
200
+ <p>
201
+ {user}, {requests} requests
202
+ </p>
203
+ <button onClick={() => session.set({ user: "nobody", requests })}>
204
+ sign out
205
+ </button>
206
+ </div>
207
+ );
208
+ }
209
+ ```
210
+
211
+ `session.use()` is the same hook your screens call, on the same object. Press a button on a screen
212
+ and the tab follows, because there is one value and not a copy of one.
213
+
214
+ What does not reach a tab is **React context**: a tab is its own root, so the app's providers are in
215
+ a different tree.
216
+
217
+ ## The bar at the top
218
+
219
+ Every tab gets one, drawn by this package: its `name`, and a button that renders the component
220
+ again. Nothing to add and nothing to wire up, and your component starts under it.
221
+
222
+ That button is for state a component reads but React is not watching. Everything else redraws on its
223
+ own, because a tab is ordinary React.
224
+
225
+ ## Text inputs
226
+
227
+ Use `defaultValue`, not `value`.
228
+
229
+ A controlled `TextInput` keeps its native view in step by sending it a command, and a tab has no
230
+ native view: the ref it gets is a stand-in, so React Native warns that `dispatchCommand` was given a
231
+ ref that is not a native component, and typing throws. Uncontrolled, it never asks, and
232
+ `onChangeText` still arrives on every keystroke.
233
+
234
+ ```tsx
235
+ <TextInput defaultValue="" onChangeText={setQuery} />
236
+ ```
237
+
238
+ Clear it by remounting, which is what a `key` that changes does. A `div`-and-`input` tab has none of
239
+ this, because React and the browser own both ends of it.
240
+
241
+ ## What it cannot do
242
+
243
+ Touch a real element. A `ref` gets a stand-in, so a canvas, a measurement or a DOM library has
244
+ nothing to work with, and an event arrives as `{ type, target: { value, checked } }` rather than the
245
+ event itself, with `preventDefault` and `stopPropagation` there but doing nothing.
246
+
247
+ Everything else a devtools tab usually wants needs none of that: reading the app's state, calling
248
+ into it, drawing a table, filtering a list.
249
+
250
+ ## Several tabs
251
+
252
+ Call `registerTab` once per tab. Each gets its own React root, so one tab re-rendering does not
253
+ touch another, and a tab nobody has opened still runs: it is the app rendering, not the panel.
254
+
255
+ Tabs identify themselves. An id is built from `name` at startup, numbered if two tabs share a name,
256
+ and every message is stamped and filtered with it, so two tabs never see each other's traffic and
257
+ nothing you write can get that wrong.
258
+
259
+ ## The tab's symbol
260
+
261
+ `icon` is text shown after the tab's name, defaulting to this package's mark. Any character works,
262
+ so an emoji does too. It is text rather than an image because React Native DevTools' own icon slots
263
+ take an element, and every way of putting one there loses its drawing.
264
+
265
+ ## In a release build
266
+
267
+ `registerTab` is safe to call unguarded. A release build installs no debugger dispatcher, so nothing
268
+ connects, your component is never rendered, and its effects never run. All the call costs is an entry
269
+ in an array.
270
+
271
+ To keep the tabs out of the release bundle as well, put the calls in a module of their own and pull
272
+ it in behind `__DEV__`:
273
+
274
+ ```tsx
275
+ if (__DEV__) require("../devtools");
276
+ ```
277
+
278
+ Metro replaces `__DEV__` with `false` and folds the dead branch away _before_ it collects
279
+ dependencies, so that module and everything it imports is left out of the bundle entirely. It has to
280
+ be a `require` rather than an `import`, because an `import` is hoisted and runs whatever the branch
281
+ around it says.
282
+
283
+ The Metro wrap costs a release bundle nothing either way. The only thing it adds to the config is
284
+ `server.enhanceMiddleware`, which nothing but a dev server reads. It touches no transformer, no
285
+ resolver and no serializer, so the bundle is the same with it as without.
286
+
287
+ The config is also the one place that `__DEV__` guard does not work. The global belongs to the app's
288
+ bundle, while `metro.config.js` runs in Node, where it does not exist, so `if (!__DEV__) return
289
+ config` throws before Metro starts. Metro reads the config when building for release too, so the
290
+ package has to be installed then: keep it out of `devDependencies` if your release install skips
291
+ those.
292
+
293
+ ## When the tab does not appear
294
+
295
+ Every failure says so, either in Metro's output or in the DevTools console.
296
+
297
+ **`[devtools] React Native DevTools was not found, so no tab is served.`** The helper could not
298
+ resolve `@react-native/debugger-frontend` from your project. A monorepo that hoists oddly is the
299
+ usual cause, and `frontendPath` is the way out:
300
+
301
+ Ask your project for the path. The package's entry point exports it as a string, so this prints it:
302
+
303
+ ```sh
304
+ node -p "require('@react-native/debugger-frontend')"
305
+ ```
306
+
307
+ Run that from your app's directory, and hand the result over:
308
+
309
+ ```js
310
+ module.exports = withReactNativeDevtoolsTab(getDefaultConfig(__dirname), {
311
+ frontendPath:
312
+ "/your/app/node_modules/@react-native/debugger-frontend/dist/third-party/front_end",
313
+ });
314
+ ```
315
+
316
+ Note how deep it sits. It is the directory the files are actually in, several levels below the
317
+ package root, not the package root itself. `ls` it and you should see `rn_fusebox.html`, which is
318
+ the page DevTools opens. If the `node -p` fails too, the package is genuinely not installed where
319
+ your app can see it, and the fix is the install rather than this option.
320
+
321
+ **`[devtools] could not point the debugger shortcut at the tab.`** The frontend was found but
322
+ `@react-native/dev-middleware` was not, so DevTools opens on its own route with no tab script in it.
323
+ Two copies of that package in one install is the usual reason.
324
+
325
+ **`[devtools] the app never installed its devtools dispatcher`** Printed in the DevTools console
326
+ after ten seconds of asking. The panel is up and the app is not answering, so either the app's bundle
327
+ never pulled this package in, or it did so behind a `__DEV__` guard in a build where that is false.
328
+
329
+ Nothing printed at all, and no tab: Metro was not restarted after the config changed.
330
+
331
+ ## How it works
332
+
333
+ React Native DevTools is a Chrome DevTools frontend fork, served by `@react-native/dev-middleware`.
334
+ This serves that same frontend from its own route, adds a nonce to the page's CSP and injects one
335
+ script, and that script re-imports the frontend's own modules to reach `InspectorView.addPanel`.
336
+ The tab's body is an iframe, and it reaches the device over the debugger connection the frontend
337
+ already has, tagged with the tab's own id so it never crosses React DevTools' own traffic.
338
+
339
+ Inside that iframe is a custom React reconciler's other half. The app's React commits produce a list
340
+ of changes (create this node, move that one, set these props) and the page replays them onto real
341
+ elements. A function prop cannot be sent, so each is swapped for the position it sits at in the tree;
342
+ the page calls back with that position and the app runs the closure it stands for.
343
+
344
+ Neither `@react-native/debugger-frontend` nor `@react-native/dev-middleware` is declared here. Both
345
+ already arrive with `react-native` and are versioned in lockstep with it, so the Metro helper
346
+ resolves them at run time starting from whatever is serving your project. The copy it uses is
347
+ therefore the copy your dev server uses. A pin of our own could land a version that speaks slightly
348
+ different CDP to your app.
349
+
350
+ ## Playground
351
+
352
+ `example/` is an Expo app for working on this package. It registers five tabs: a counter sharing a
353
+ value with the app's own screen, a shell running a command on the machine Metro is on, a check that a
354
+ string which looks like markup renders as that string, a reader for the app's MMKV store, and one
355
+ querying its SQLite database.
356
+
357
+ ```sh
358
+ cd example && bun run start
359
+ ```